Deferring sync while offline
ใน Push & Pull Sync client drain outbox ทันทีที่ edit ลงมา — แต่เฉพาะเมื่อมี network ตอน offline การ push ล้มเหลว op ค้างอยู่ใน queue และ อะไรสักอย่าง ต้องจำไว้ว่าจะลองใหม่ทีหลัง จนถึงตอนนี้ “อะไรสักอย่าง” นั้นคือ user เปิด app ขึ้นมาใหม่ บทนี้ยกงานนั้นให้ browser
Background Sync API ให้ page register ความตั้งใจที่มีชื่อได้ — “ฉันมีงานที่ต้องใช้ network” — แล้วเดินจากไป browser ถือ request นั้นไว้แล้ว fire sync event ใน Service Worker ของคุณ เมื่อ connectivity กลับมา ต่อให้ปิด tab ไปนานแล้วก็ตาม เรา register tag เดียวชื่อ sync-notes ทุกครั้งที่ push ถูกเลื่อนเพราะเรา offline
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”client helper เล็ก ๆ ชื่อ requestSync() ที่ขอให้ browser schedule background sync ภายใต้ tag sync-notes บวกกับหนึ่งบรรทัดใน write path ของ store ที่เรียก helper นี้เมื่อ push ตอนนี้ทำไม่ได้ ส่วนฝั่ง Service Worker — ที่ drain outbox จริง ๆ — คือบทถัดไป ตรงนี้เราแค่ ตั้ง การ retry ไว้
edit ที่ทำ offline persist ลง IndexedDB อย่างปลอดภัยแล้วผ่าน WASM CRDT; note ไม่ได้เสี่ยงอะไร สิ่งที่ขาดคือการส่งมอบ: op ที่ค้างใน queue ต้องไปถึง server ในที่สุด การ poll เปลือง battery และก็ยังพลาดจังหวะ “คุณกลับมา online แล้ว” ได้ถึงหนึ่งช่วง poll interval Background Sync พลิกเรื่องนี้ — browser รู้อยู่แล้วว่าวินาทีไหนที่ radio กลับมาพอดี ก็ให้ browser ปลุกเราตอนนั้น การ register tag นั้นถูกและ idempotent: ขอบ่อยแค่ไหนก็ได้; browser รวม tag ซ้ำ ๆ ให้เป็น pending sync เดียว
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Background Sync tag vs. pushing on every edit
- Pros: browser เป็นเจ้าของการ retry — fire เมื่อ connectivity มีจริง อยู่รอดแม้ปิด tab และ back off เองอัตโนมัติเมื่อล้มเหลวซ้ำ ๆ ไม่มี timer ไม่มี logic reconnect ใน app คุณ
- Cons: ตอนนี้มีแค่บน Chromium (ไม่มีใน Firefox ไม่มีใน Safari) จึงต้องมองเป็น enhancement ที่มี fallback ไม่ใช่ path เดียว fallback นั้นคือบทถัดไป
Registering on demand vs. always registering
- Pros: register เฉพาะตอนที่ push ถูกเลื่อนจริง ทำให้ความตั้งใจนั้นมีความหมาย — tag
sync-notesที่ค้างอยู่แปลว่า “มี op ที่ยังไม่ได้ส่ง” ไม่มากไปกว่านั้น - Cons: คุณต้อง feature-detect และ guard ทุกการเรียก เพราะ
registration.syncไม่มีบน browser ที่ไม่รองรับ และ throw ถ้า worker ยังไม่ active
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/web/src/background-sync.ts
หัวข้อที่มีชื่อว่า “1. apps/web/src/background-sync.ts”feature-detect แล้ว register tag ทุกอย่างถูก guard ไว้ เพื่อให้การเรียกบน Safari เป็น no-op ที่ไม่มีผลอะไร — ผู้เรียกจะตกไปที่ fallback ที่เราสร้างบทถัดไป
// The single Background Sync tag OfflineNotes uses.export const SYNC_TAG = 'sync-notes';
// True only where one-off Background Sync actually exists.export function hasBackgroundSync(): boolean { return 'serviceWorker' in navigator && 'SyncManager' in window;}
// Arm a background sync. Safe to call repeatedly — the browser// coalesces duplicate tags into a single pending sync.export async function requestSync(): Promise<boolean> { if (!hasBackgroundSync()) return false; try { const registration = await navigator.serviceWorker.ready; await registration.sync.register(SYNC_TAG); return true; } catch (err) { // register() throws InvalidStateError if the worker isn't active yet, // or NotAllowedError if the user disabled background sync. console.warn('[sync] Background Sync registration failed', err); return false; }}2. apps/web/src/store.ts
หัวข้อที่มีชื่อว่า “2. apps/web/src/store.ts”store เรียก enqueueOp หลังทุก local edit อยู่แล้ว (Module 10) เพิ่มทางแยก: push ตอนนี้ถ้าเรา online ไม่งั้นเลื่อนไปให้ Background Sync การทิ้ง op ไว้ใน outbox ถูกต้องพอดี — เพราะนั่นคือ payload ของการ retry
// apps/web/src/store.ts (inside the edit path, after enqueueOp)import { pushOutbox } from './sync'; // Module 10import { requestSync } from './background-sync';
async function afterLocalEdit(noteId: string, op: unknown): Promise<void> { await enqueueOp(noteId, op); // durable first — Module 3
if (navigator.onLine) { await pushOutbox(); // deliver immediately } else { await requestSync(); // defer: retry when back online }}สังเกตลำดับ: enqueueOp รัน ก่อน ความพยายามส่งมอบทั้งสองแบบ op คงทนอยู่ใน IndexedDB ไม่ว่า network จะทำอะไรต่อ — การเลื่อน sync ไม่เคยเสี่ยงต่อ edit เสี่ยงแค่การส่งมอบเท่านั้น
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”เริ่ม app และ sync server:
pnpm --filter sync dev # http://localhost:8787pnpm --filter web dev # http://localhost:4321จากนั้นใน browser ยืนยันว่า tag ถูก register ขณะ offline:
- เปิด DevTools → Application → Service Workers; ยืนยันว่า worker activated แล้ว
- เปิด Application → Background services → Background Sync แล้วคลิก Record
- ใน panel Network สลับ throttling ไปเป็น Offline
- แก้ note สักอัน push ทำไม่สำเร็จ
requestSync()จึงรัน
ที่ควรได้: การ register sync-notes โผล่ใน Background Sync recorder log ไว้ว่า “Registered sync”:
Background Sync sync-notes Registered sync (waiting for connectivity)สลับ Network กลับเป็น Online แล้ว panel เดียวกัน log การ dispatch — นั่นคือ sync event ของบทถัดไปที่ fire สุดท้าย ยืนยันว่า build สะอาด:
pnpm --filter web buildที่ควรได้: Complete! โดยไม่มี type error จาก background-sync.ts
ตรวจสอบความเข้าใจ:
- ทำไม
enqueueOpรัน ก่อน ทางแยก online/offline แทนที่จะรันเฉพาะบน branch offline? requestSync()return อะไรบน Safari และคาดว่า code path ไหนจะเข้ามารับช่วงต่อตรงนั้น?- Background Sync รวม tag ซ้ำเข้าด้วยกัน ทำไมนั่นเป็น feature ไม่ใช่ bug สำหรับ write path ที่ edit เยอะของเรา?
registration.sync.register()throw ได้ ยกหนึ่งเงื่อนไขที่ throw และอธิบายว่าทำไมการกลืนไว้ (returnfalse) จึงเป็นทางเลือกที่ถูกตรงนี้
push ที่ถูกเลื่อนตอนนี้เป็นความตั้งใจที่ register ไว้ ไม่ใช่ edit ที่หายไป เมื่อ device offline store enqueue op ลง outbox ที่คงทน แล้วขอให้ browser fire sync-notes เมื่อ network กลับมา เราตั้งการ retry ไว้แล้ว แต่ยังไม่มีอะไร drain queue ต่อไป Retry when online → จัดการ sync event ใน Service Worker และเพิ่ม fallback สำหรับ browser ที่ไม่มี Background Sync