mdv-live 0.5.19 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.20] - 2026-05-18
9
+
10
+ ### Fixed — Presenter View のノート編集が毎回 STALE エラー
11
+
12
+ Presenter View で speaker note を編集すると、毎回一瞬
13
+ `保存失敗: STALE — file changed externally; please reload`(赤)が出る
14
+ バグを修正。同じ deck を mdv のタブ/ウィンドウで 2 つ以上開いていると
15
+ 必ず発生していた。
16
+
17
+ - 原因: Presenter View はノート保存を自前で行わず、`BroadcastChannel` の
18
+ `edit-note` メッセージで **メインウィンドウに保存を委譲** する。この
19
+ メッセージは同一ブラウザの **全メインウィンドウ** に届くため、deck を
20
+ 複数ウィンドウで開いていると **全員が同じ `If-Match` で PUT** し、サーバの
21
+ 楽観ロックで 1 つだけ成功、残りが 412 STALE になっていた。負けた側が
22
+ `note-saved {ok:false, STALE}` を Presenter にブロードキャストし、赤い
23
+ エラー表示になっていた(保存自体は 1 つ成功するため「一瞬だけ」見える)
24
+ - 各メインウィンドウに一意な `windowId` を付与。`slides` メッセージに
25
+ `sourceWindowId` を載せ、Presenter は最初に deck を返したウィンドウを
26
+ saver として固定、`edit-note` に `targetWindowId` を載せて **その 1
27
+ ウィンドウだけが保存** するようルーティング
28
+ - saver ウィンドウが閉じた/凍結した場合は保存タイムアウト(6 秒)で
29
+ 検知し、`find-saver` 問い合わせで deck を持つ別ウィンドウ(非アクティブ
30
+ な背景タブ含む)を探して再ピン・再送するフェイルオーバーを実装
31
+ - 各 `edit-note` に一意な `requestId` を付与し `note-saved` で echo。
32
+ 古い保存の ack が新しい保存のフェイルオーバー監視を誤って解除する問題を
33
+ 防止
34
+ - `saveNote` が「対象 Marp タブなし」のとき `note-saved` をブロードキャスト
35
+ せず return していたため Presenter が `保存中…` のまま固まり得た問題も
36
+ 併せて修正(`code:'NO_DECK'` を返してフェイルオーバーを誘発)
37
+
38
+ ### Verified
39
+
40
+ - 既存 278 + 新規 3(`tests/test-presenter-channel.js`: `newWindowId` の
41
+ 一意性 / フォールバック / モジュール API)
42
+ - Playwright dogfood: メインウィンドウ 2 枚 + Presenter で speaker note を
43
+ 編集 → STALE が出ないこと、メインウィンドウ 1 枚で回帰がないことを実機確認
44
+
8
45
  ## [0.5.19] - 2026-05-16
9
46
 
10
47
  ### Fixed — Marp `![bg]` background images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.19",
3
+ "version": "0.5.20",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/static/app.js CHANGED
@@ -1212,6 +1212,10 @@
1212
1212
 
1213
1213
  const PresenterView = {
1214
1214
  channel: null,
1215
+ // Unique id for this main window. The presenter echoes it back as
1216
+ // `edit-note.targetWindowId` so that exactly one main window saves,
1217
+ // even when the same deck is open in several windows.
1218
+ windowId: null,
1215
1219
  presenterWindow: null,
1216
1220
  saveQueue: null, // MDVSaveQueue instance (created in init)
1217
1221
  // Map<path, etag> — own-save chain rebase. We track presenter and
@@ -1246,7 +1250,7 @@
1246
1250
  // can autosave in environments (older browsers / sandboxed
1247
1251
  // webviews) where the Presenter window cannot be opened.
1248
1252
  this.saveQueue = window.MDVSaveQueue.createSaveQueue({
1249
- saveFn: (path, slideIndex, note, etag, origin) => {
1253
+ saveFn: (path, slideIndex, note, etag, origin, requestId) => {
1250
1254
  let useEtag = etag;
1251
1255
  const tab = state.tabs.find((t) => t.path === path);
1252
1256
  // Pick the "own etag" map that matches this save's
@@ -1259,7 +1263,7 @@
1259
1263
  : this.lastSavedEtag;
1260
1264
  const own = ownMap.get(path);
1261
1265
  if (tab && own && tab.etag === own) useEtag = own;
1262
- return this.saveNote(path, slideIndex, note, useEtag, origin);
1266
+ return this.saveNote(path, slideIndex, note, useEtag, origin, requestId);
1263
1267
  }
1264
1268
  });
1265
1269
 
@@ -1279,6 +1283,7 @@
1279
1283
  // their channel.postMessage calls (channel === null).
1280
1284
  if (typeof BroadcastChannel !== 'undefined'
1281
1285
  && window.MDVPresenterChannel) {
1286
+ this.windowId = window.MDVPresenterChannel.newWindowId();
1282
1287
  this.channel = window.MDVPresenterChannel.create();
1283
1288
  if (this.channel) {
1284
1289
  this.channel.addEventListener('message', (e) => {
@@ -1287,11 +1292,38 @@
1287
1292
  this.broadcastSlides();
1288
1293
  } else if (msg.type === 'goto') {
1289
1294
  this.gotoSlide(msg.index);
1295
+ } else if (msg.type === 'find-saver') {
1296
+ // Failover discovery: the presenter lost its
1297
+ // saver and asks who can save `path`. Answer if
1298
+ // this window holds that deck in ANY tab —
1299
+ // saveNote() resolves by path, so an inactive
1300
+ // background tab counts (broadcastSlides only
1301
+ // reports the active tab and would miss it).
1302
+ if (msg.path
1303
+ && state.tabs.some((t) => t.path === msg.path && t.isMarp)) {
1304
+ this.channel.postMessage({
1305
+ type: 'saver-here',
1306
+ path: msg.path,
1307
+ windowId: this.windowId
1308
+ });
1309
+ }
1290
1310
  } else if (msg.type === 'edit-note') {
1291
1311
  if (!msg.path) return;
1312
+ // Route: only the main window the presenter
1313
+ // picked as its saver performs the save. Without
1314
+ // this, every main window showing the same deck
1315
+ // fires its own PUT and all but one collide on
1316
+ // the optimistic lock → spurious "STALE" in the
1317
+ // presenter. A missing targetWindowId (older
1318
+ // presenter build) falls back to handling it so
1319
+ // saves still work, just without dedup.
1320
+ if (msg.targetWindowId
1321
+ && msg.targetWindowId !== this.windowId) {
1322
+ return;
1323
+ }
1292
1324
  this.saveQueue.enqueue(
1293
1325
  msg.path, msg.slideIndex, msg.note,
1294
- msg.etag || null, 'presenter'
1326
+ msg.etag || null, 'presenter', msg.requestId
1295
1327
  );
1296
1328
  }
1297
1329
  });
@@ -1317,11 +1349,16 @@
1317
1349
  // next autosave would skip the STALE conflict it should otherwise
1318
1350
  // hit and silently overwrite the inline edit).
1319
1351
  //
1352
+ // `requestId` is the opaque token from the presenter's edit-note;
1353
+ // echoing it back in note-saved lets the presenter match this
1354
+ // result to the exact save it sent (older saves' acks then can't
1355
+ // cancel a newer save's failover timer).
1356
+ //
1320
1357
  // Returns { ok, etag?, normalizedNote?, reason?, code? } so saveQueue
1321
1358
  // can forward the result to enqueue() awaiters (the main-window inline
1322
1359
  // notes panel reads this). The presenter window still gets results via
1323
1360
  // the existing channel.postMessage('note-saved') broadcast.
1324
- async saveNote(path, slideIndex, note, editTimeEtag, origin) {
1361
+ async saveNote(path, slideIndex, note, editTimeEtag, origin, requestId) {
1325
1362
  const broadcast = (payload) => {
1326
1363
  // No-op when BroadcastChannel was unavailable at init —
1327
1364
  // inline autosaves still work because callers also read
@@ -1329,15 +1366,28 @@
1329
1366
  if (!this.channel) return;
1330
1367
  this.channel.postMessage({
1331
1368
  type: 'note-saved',
1369
+ path,
1332
1370
  slideIndex,
1333
1371
  origin: origin || 'unknown',
1372
+ sourceWindowId: this.windowId,
1373
+ requestId,
1334
1374
  ...payload
1335
1375
  });
1336
1376
  };
1337
1377
 
1338
1378
  const tab = state.tabs.find((t) => t.path === path);
1339
1379
  if (!tab || !tab.isMarp) {
1340
- return { ok: false, reason: 'No active Marp tab' };
1380
+ // This window no longer holds the deck (tab closed / switched
1381
+ // away). Broadcast a NO_DECK failure so a presenter that
1382
+ // routed here can fail over to another window instead of
1383
+ // hanging on "保存中…" or surfacing a dead-end error.
1384
+ const result = {
1385
+ ok: false,
1386
+ code: 'NO_DECK',
1387
+ reason: 'No active Marp tab'
1388
+ };
1389
+ broadcast(result);
1390
+ return result;
1341
1391
  }
1342
1392
  const ifMatch = editTimeEtag || tab.etag;
1343
1393
  if (!ifMatch) {
@@ -1447,7 +1497,8 @@
1447
1497
  this.channel.postMessage({
1448
1498
  type: 'slides',
1449
1499
  empty: true,
1450
- reason: 'main-switched-away'
1500
+ reason: 'main-switched-away',
1501
+ sourceWindowId: this.windowId
1451
1502
  });
1452
1503
  return;
1453
1504
  }
@@ -1459,7 +1510,8 @@
1459
1510
  notes: tab.notes || [],
1460
1511
  notesMultiplicity: tab.notesMultiplicity || [],
1461
1512
  etag: tab.etag || null,
1462
- current: marpCurrentSlide
1513
+ current: marpCurrentSlide,
1514
+ sourceWindowId: this.windowId
1463
1515
  });
1464
1516
  },
1465
1517
 
@@ -8,15 +8,33 @@
8
8
  * Message types (discriminated by `type`):
9
9
  *
10
10
  * main → presenter
11
- * { type: 'slides', path, html, css, etag, notes, notesMultiplicity, current }
12
- * { type: 'slides', empty: true, reason } ← clear / no-deck
11
+ * { type: 'slides', path, html, css, etag, notes, notesMultiplicity,
12
+ * current, sourceWindowId }
13
+ * { type: 'slides', empty: true, reason, sourceWindowId } ← clear / no-deck
13
14
  * { type: 'index', index }
14
- * { type: 'note-saved', slideIndex, ok, etag?, normalizedNote?, code?, reason? }
15
+ * { type: 'note-saved', path, slideIndex, ok, etag?, normalizedNote?,
16
+ * code?, reason?, origin, sourceWindowId, requestId }
17
+ * { type: 'saver-here', path, windowId } ← failover reply
15
18
  *
16
19
  * presenter → main
17
20
  * { type: 'request-slides' }
18
21
  * { type: 'goto', index }
19
- * { type: 'edit-note', path, etag, slideIndex, note }
22
+ * { type: 'edit-note', path, etag, slideIndex, note, requestId,
23
+ * targetWindowId }
24
+ * { type: 'find-saver', path } ← failover query
25
+ *
26
+ * Window routing: every main window has a unique `windowId`. Each `slides`
27
+ * message carries the broadcasting window's id as `sourceWindowId`; the
28
+ * presenter pins the first one that can serve the deck and echoes it back as
29
+ * `edit-note.targetWindowId`. Only that one main window performs the save.
30
+ * Without this, N main windows showing the same deck each fire their own PUT
31
+ * and all but one collide on the optimistic lock → spurious "STALE" errors.
32
+ *
33
+ * Failover: if the pinned saver stops answering, the presenter broadcasts
34
+ * `find-saver`; any main window holding the deck (active OR background tab)
35
+ * replies `saver-here` and the presenter re-pins it. Each `edit-note` carries
36
+ * a unique `requestId` echoed back in `note-saved` so the presenter matches a
37
+ * result to the exact save that produced it.
20
38
  */
21
39
  (function () {
22
40
  'use strict';
@@ -27,7 +45,17 @@
27
45
  return new BroadcastChannel(CHANNEL_NAME);
28
46
  }
29
47
 
48
+ // Per-window identifier used to route presenter saves to a single main
49
+ // window. crypto.randomUUID is available on localhost (a secure context);
50
+ // the fallback keeps things working in any odd environment.
51
+ function newWindowId() {
52
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
53
+ return crypto.randomUUID();
54
+ }
55
+ return 'w-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
56
+ }
57
+
30
58
  if (typeof globalThis !== 'undefined') {
31
- globalThis.MDVPresenterChannel = { CHANNEL_NAME, create };
59
+ globalThis.MDVPresenterChannel = { CHANNEL_NAME, create, newWindowId };
32
60
  }
33
61
  })();
@@ -11,10 +11,12 @@
11
11
  * - Crucially, coalescing is scoped per origin: an inline save and a
12
12
  * presenter save for the same slide do NOT replace each other. Both run
13
13
  * serially so neither editor silently loses a draft.
14
- * - `saveFn(path, slideIndex, note, etag, origin)` is supplied by the
15
- * caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
14
+ * - `saveFn(path, slideIndex, note, etag, origin, requestId)` is supplied by
15
+ * the caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
16
16
  * queue uses for keying and also forwards verbatim so saveFn can route
17
- * notifications back to the right editor.
17
+ * notifications back to the right editor. `requestId` is an optional
18
+ * opaque token forwarded verbatim so a caller can correlate the save's
19
+ * result with the request that triggered it.
18
20
  * - enqueue() returns a Promise that resolves with the saveFn's result (or a
19
21
  * COALESCED sentinel). Existing callers that ignore the return value or
20
22
  * skip the origin argument keep working unchanged.
@@ -32,7 +34,7 @@
32
34
  /** @type {Map<string, { pending: Map<string, {slideIndex:number, note:string, etag:string|null, origin:string|undefined, resolve:Function}>, isDraining: boolean }>} */
33
35
  const queue = new Map();
34
36
 
35
- function enqueue(path, slideIndex, note, etag, origin) {
37
+ function enqueue(path, slideIndex, note, etag, origin, requestId) {
36
38
  return new Promise((resolve) => {
37
39
  let entry = queue.get(path);
38
40
  if (!entry) {
@@ -44,7 +46,7 @@
44
46
  if (existing) {
45
47
  existing.resolve({ ok: false, reason: 'COALESCED' });
46
48
  }
47
- entry.pending.set(key, { slideIndex, note, etag, origin, resolve });
49
+ entry.pending.set(key, { slideIndex, note, etag, origin, requestId, resolve });
48
50
  if (!entry.isDraining) drain(path);
49
51
  });
50
52
  }
@@ -62,7 +64,8 @@
62
64
  let result;
63
65
  try {
64
66
  result = await saveFn(
65
- path, payload.slideIndex, payload.note, payload.etag, payload.origin
67
+ path, payload.slideIndex, payload.note, payload.etag,
68
+ payload.origin, payload.requestId
66
69
  );
67
70
  } catch (err) {
68
71
  console.error('saveQueue saveFn error', err);
@@ -287,6 +287,33 @@
287
287
  let deckPath = '';
288
288
  let saveTimer = null;
289
289
 
290
+ // Window-routing + failover state: the presenter delegates saving to
291
+ // ONE main window (saverWindowId — the first that can serve this deck).
292
+ // saveAckTimer detects when that window stops answering. inflightSave /
293
+ // pendingSave hold a FROZEN copy of the edit ({path, slideIndex, note,
294
+ // etag, requestId}) so a failover resends the original edit, not
295
+ // whatever the editor shows after a later blur / slide navigation.
296
+ let saverWindowId = null;
297
+ let saveAckTimer = null;
298
+ let findSaverTimer = null; // bounds the wait for a find-saver reply
299
+ let inflightSave = null; // routed, awaiting ack | null
300
+ let pendingSave = null; // deferred until a saver is (re)pinned | null
301
+ const SAVE_ACK_TIMEOUT_MS = 6000;
302
+ const FIND_SAVER_TIMEOUT_MS = 3000;
303
+
304
+ // Each edit-note carries a globally-unique requestId so its note-saved
305
+ // ack matches the exact save that produced it — an older save's ack
306
+ // must never clear a newer save's failover timer. The presenterId
307
+ // prefix keeps ids distinct across multiple presenter windows.
308
+ const presenterId = (window.MDVPresenterChannel && window.MDVPresenterChannel.newWindowId)
309
+ ? window.MDVPresenterChannel.newWindowId()
310
+ : ('p-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
311
+ let saveRequestSeq = 0;
312
+ function newRequestId() {
313
+ saveRequestSeq += 1;
314
+ return presenterId + ':' + saveRequestSeq;
315
+ }
316
+
290
317
  const notesBanner = document.getElementById('notesBanner');
291
318
 
292
319
  function applyCss(css) {
@@ -384,21 +411,16 @@
384
411
 
385
412
  function loadSlides(payload) {
386
413
  if (payload.empty) {
387
- // Main window switched to a non-Marp tab or closed the deck — clear
388
- // everything so the user can't keep editing notes that would land in
389
- // the wrong file. saveQueue retains nothing because path mismatches.
390
- slidesHtml = '';
391
- cssText = '';
392
- applyCss('');
414
+ // Once a deck is shown, the presenter never blanks itself on an
415
+ // `empty`: with multiple main windows `request-slides` is global and
416
+ // any window on a non-Marp tab including the pinned saver, which
417
+ // can still save the deck from a background tab — broadcasts `empty`.
418
+ // Blanking mid-presentation on that noise is wrong; keep the current
419
+ // slides and let save routing / failover handle reachability. Only
420
+ // an `empty` arriving before any deck has loaded shows the hint.
421
+ if (deckPath !== '') return;
393
422
  fillStage(stageCurrent, '<div class="empty-state">メイン画面で Marp ファイルを開いてください。</div>');
394
423
  fillStage(stageNext, '');
395
- notes = [];
396
- notesMultiplicity = [];
397
- deckEtag = null;
398
- deckPath = '';
399
- slideCount = 0;
400
- counter.textContent = '– / –';
401
- setNotesText('');
402
424
  applyReadOnlyState();
403
425
  return;
404
426
  }
@@ -416,7 +438,20 @@
416
438
  notes = Array.isArray(payload.notes) ? payload.notes : [];
417
439
  notesMultiplicity = Array.isArray(payload.notesMultiplicity) ? payload.notesMultiplicity : [];
418
440
  deckEtag = payload.etag || null;
441
+ // Detect a deck switch BEFORE updating deckPath: the presenter window
442
+ // can be reused by another main window for another deck, and the old
443
+ // saver may not hold the new deck (→ "No active Marp tab", no retry).
444
+ const deckChanged = !!payload.path && payload.path !== deckPath;
419
445
  deckPath = payload.path || deckPath;
446
+ // Pin the window serving this deck as our saver, so edit-note saves
447
+ // route to exactly one window (no duplicate PUTs). A non-empty
448
+ // `slides` proves the sender holds the deck. Re-pin on a deck switch
449
+ // (the presenter window can be reused by another main window).
450
+ // Failover (saver gone) is handled separately via find-saver /
451
+ // saver-here, which also discovers inactive background deck tabs.
452
+ if (payload.sourceWindowId && (saverWindowId === null || deckChanged)) {
453
+ saverWindowId = payload.sourceWindowId;
454
+ }
420
455
  slideCount = stageCurrent.querySelectorAll('.marpit > svg[data-marpit-svg]').length;
421
456
  render(typeof payload.current === 'number' ? payload.current : currentIndex);
422
457
  }
@@ -451,24 +486,97 @@
451
486
  }
452
487
  }
453
488
 
489
+ // Broadcast a find-saver query for `path` and bound the wait: a deck
490
+ // open in ANY window's tab (active OR background) answers `saver-here`;
491
+ // if none does within the timeout the deck has no reachable window, so
492
+ // surface an honest error instead of spinning on 保存中… forever.
493
+ function requestSaver(path) {
494
+ channel.postMessage({ type: 'find-saver', path });
495
+ if (findSaverTimer !== null) clearTimeout(findSaverTimer);
496
+ findSaverTimer = setTimeout(() => {
497
+ findSaverTimer = null;
498
+ if (pendingSave !== null) {
499
+ pendingSave = null;
500
+ setSaveStatus('保存失敗: 保存先ウィンドウが見つかりません', 'err');
501
+ }
502
+ }, FIND_SAVER_TIMEOUT_MS);
503
+ }
504
+
505
+ // Re-pin a live saver and resend `payload` (the exact edit that could
506
+ // not be delivered). Used when the saver window stops answering or no
507
+ // longer holds the deck.
508
+ function failOver(payload) {
509
+ if (saveAckTimer !== null) {
510
+ clearTimeout(saveAckTimer);
511
+ saveAckTimer = null;
512
+ }
513
+ saverWindowId = null;
514
+ inflightSave = null;
515
+ if (!payload) return;
516
+ pendingSave = payload;
517
+ requestSaver(payload.path);
518
+ }
519
+
520
+ // The saver window never acknowledged our edit-note (it was closed or
521
+ // frozen). Fail over, resending the exact edit that timed out.
522
+ function onSaveAckTimeout() {
523
+ saveAckTimer = null;
524
+ failOver(inflightSave);
525
+ }
526
+
454
527
  function sendNoteSave() {
455
- // Use the slide / deck / etag captured when the user started editing —
456
- // not the live values, which can change under us if the main window
457
- // navigates, switches tabs, or receives a watcher update during the
458
- // debounce. Sending the live etag would let a watcher-refreshed etag
459
- // pass If-Match against the post-edit deck.
528
+ // Freeze the slide / deck / etag captured when the user started
529
+ // editing — not the live values, which can change under us if the
530
+ // main window navigates, switches tabs, or receives a watcher update
531
+ // during the debounce. The frozen payload also lets a failover resend
532
+ // the exact edit even after a later blur / slide navigation.
460
533
  const idx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
461
- const path = editingPath || deckPath;
462
- const etag = editingEtag || deckEtag;
463
534
  const value = getNotesText();
464
535
  notes[idx] = value.trim();
536
+ postEditNote({
537
+ path: editingPath || deckPath,
538
+ slideIndex: idx,
539
+ note: value,
540
+ etag: editingEtag || deckEtag
541
+ });
542
+ }
543
+
544
+ // Route one frozen edit payload to the pinned saver, or defer it until
545
+ // a saver is (re)pinned.
546
+ function postEditNote(payload) {
465
547
  setSaveStatus('保存中…');
548
+
549
+ // No live saver pinned yet (first edit before slides arrived, or the
550
+ // previous saver window went away). Routing an edit-note without a
551
+ // targetWindowId would let EVERY main window save it and collide on
552
+ // the optimistic lock — the exact bug this routing fixes. Instead ask
553
+ // who can serve this deck; the saver-here handler re-pins and resends.
554
+ if (saverWindowId === null) {
555
+ pendingSave = payload;
556
+ requestSaver(payload.path);
557
+ return;
558
+ }
559
+
560
+ // Fresh requestId per actual send: a failover resend must NOT reuse
561
+ // the timed-out original's id, or that original's late ack would be
562
+ // mistaken for the resend's ack.
563
+ inflightSave = {
564
+ path: payload.path,
565
+ slideIndex: payload.slideIndex,
566
+ note: payload.note,
567
+ etag: payload.etag,
568
+ requestId: newRequestId()
569
+ };
570
+ if (saveAckTimer !== null) clearTimeout(saveAckTimer);
571
+ saveAckTimer = setTimeout(onSaveAckTimeout, SAVE_ACK_TIMEOUT_MS);
466
572
  channel.postMessage({
467
573
  type: 'edit-note',
468
- path,
469
- etag,
470
- slideIndex: idx,
471
- note: value
574
+ path: inflightSave.path,
575
+ etag: inflightSave.etag,
576
+ slideIndex: inflightSave.slideIndex,
577
+ note: inflightSave.note,
578
+ requestId: inflightSave.requestId,
579
+ targetWindowId: saverWindowId
472
580
  });
473
581
  }
474
582
 
@@ -533,7 +641,56 @@
533
641
  // the new etag came from our own successful save.
534
642
  } else if (msg.type === 'index') {
535
643
  render(msg.index);
644
+ } else if (msg.type === 'saver-here') {
645
+ // A window answered our find-saver query. Pin it and resend the
646
+ // deferred edit — but only if it serves the exact deck that edit
647
+ // targets (find-saver is path-scoped, yet guard defensively). The
648
+ // first answer wins; later answers find pendingSave already null.
649
+ if (pendingSave !== null
650
+ && msg.windowId
651
+ && msg.path === pendingSave.path) {
652
+ if (findSaverTimer !== null) {
653
+ clearTimeout(findSaverTimer);
654
+ findSaverTimer = null;
655
+ }
656
+ saverWindowId = msg.windowId;
657
+ const queued = pendingSave;
658
+ pendingSave = null;
659
+ postEditNote(queued);
660
+ }
536
661
  } else if (msg.type === 'note-saved') {
662
+ // Is this the answer to OUR routed save? Match on the unique
663
+ // requestId so an older save's ack can't clear a newer save's
664
+ // failover timer, and a foreign save (e.g. an inline edit) for the
665
+ // same slide is ignored — inline saves carry no requestId.
666
+ const isOwnAck = inflightSave !== null
667
+ && msg.requestId != null
668
+ && msg.requestId === inflightSave.requestId;
669
+ // A note-saved that carries a requestId but isn't our current
670
+ // inflight save is a superseded / timed-out save's late ack — drop
671
+ // it so it can't overwrite status / backup / editingEtag for the
672
+ // current edit. requestId-less acks (inline edits) still fall
673
+ // through to the observer logic below.
674
+ if (!isOwnAck && msg.requestId != null) return;
675
+ if (isOwnAck) {
676
+ // The pinned saver no longer holds this deck (tab closed / switched
677
+ // away). It IS alive, but cannot save — fail over to a window that
678
+ // can, resending the same edit, instead of surfacing an error.
679
+ if (msg.code === 'NO_DECK') {
680
+ failOver(inflightSave);
681
+ return;
682
+ }
683
+ // A real answer (success OR genuine STALE) — stop the failover
684
+ // timer and let the status handling below run.
685
+ if (saveAckTimer !== null) {
686
+ clearTimeout(saveAckTimer);
687
+ saveAckTimer = null;
688
+ }
689
+ inflightSave = null;
690
+ }
691
+ // Ignore results for a different deck — a second presenter window,
692
+ // or the main window having switched decks mid-flight.
693
+ if (msg.path && deckPath && msg.path !== deckPath) return;
537
694
  const targetIdx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
538
695
  if (msg.slideIndex !== targetIdx) return;
539
696
  // Only OUR own successful saves should advance editingEtag. The