Remote-load fallbacks
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”error boundary และ fallback UI ครอบทุก remote ที่ shell mount ไว้ เพื่อว่าเมื่อ remoteEntry.js ของ remote ตัวใดโหลดไม่สำเร็จ — CDN ล่ม, deploy พัง, network สะดุด — slice นั้นจะโชว์ panel เล็ก ๆ ว่า “โหลดไม่ได้” ส่วน storefront ที่เหลือยังทำงานต่อได้ปกติ
นี่คือบทแรกของ module เพราะบอกราคาที่คุณต้องจ่ายให้ runtime composition ใน single-page app ปกติ ทั้ง bundle จะโหลดสำเร็จหรือล้มเหลวพร้อมกันทีเดียว แต่ใน Mosaic แต่ละ remote โหลดตอน runtime จาก URL ของตัวเอง แปลว่าแต่ละ remote ล้มเหลวได้แยกจากกัน — และถ้า shell ไม่จัดการตรงนี้ remote ที่พังตัวเดียวจะลากทั้งหน้าล่มไปด้วย
React.lazy(() => import('catalog/Catalog')) คืน promise ออกมา เมื่อ import resolve <Suspense> จะสลับ fallback ออกแล้วแสดง component จริงแทน แต่ <Suspense> จัดการแค่สถานะ pending เท่านั้น — ไม่มีคำตอบให้สถานะ rejected เลย ถ้า remoteEntry.js ตอบ 404 หรือ network หลุด promise นั้นจะ reject การ reject จะไหลขึ้นไปกลายเป็น render error และถ้าไม่มี boundary error จะ unmount ทุกอย่างที่อยู่เหนือขึ้นไป ทั้ง shell — nav, session, cart badge, remote ตัวอื่น — จะจอเปล่า
error boundary ดักการ reject นั้นที่จุดที่คุณเลือกไว้ใน tree แล้ว render fallback แทนที่จะปล่อยให้ไหลขึ้นไป วาง boundary ไว้รอบ remote แต่ละตัวโดยตรง แล้วรัศมีความเสียหายของ load ที่ล้มเหลวจะเท่ากับ slice เดียวพอดี นี่คือหลักการ isolation เดียวกับที่ service mesh ให้ microservices: dependency ตัวหนึ่งล่มจะทำให้ feature เดียว degrade ไม่ลามไปทั้งระบบ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”One boundary per remote vs. one boundary around the whole app
- Pros (per remote): load ที่ล้มเหลวทำให้ slice เดียวเท่านั้น degrade; nav, session, และ remote ตัวอื่นยังโต้ตอบได้ fallback เจาะจงได้ (“Catalog is unavailable”) และเสนอ retry ที่ re-import เฉพาะ remote ตัวนั้นได้
- Cons (per remote): ต้องเขียนและวาง boundary มากขึ้น และต้องออกแบบ degraded state ที่สมเหตุสมผลให้แต่ละ slice แทนที่จะมี error page กลาง ๆ หน้าเดียว
Error boundary vs. a try/catch around the import
- Pros (boundary): ดักทั้ง rejection ตอน load และ render-time error ที่ถูก throw ข้างใน remote หลัง mount แล้ว ด้วยกลไกเดียว นี่คือ seam ของ React ที่เป็น idiomatic สำหรับ “subtree นี้ล้มเหลว”
- Cons (boundary): ต้องเป็น class component (หรือ wrapper อย่าง
react-error-boundary) — ยังไม่มีรูปแบบ hook — และดักได้แค่ error ตอน render/lifecycle ไม่ใช่ข้างใน event handler ดังนั้น click handler ของ remote ยังต้อง guard ตัวเองอยู่ดี
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/shell/src/RemoteBoundary.tsx
หัวข้อที่มีชื่อว่า “1. apps/shell/src/RemoteBoundary.tsx”error boundary เล็ก ๆ ที่มี retry reset ได้ตามต้องการ ให้ failure ชั่วคราวลองใหม่ได้โดยไม่ต้อง reload ทั้งหน้า
import { Component, type ReactNode } from 'react';
type Props = { /** Shown in the fallback, e.g. "Catalog". */ name: string; children: ReactNode;};
type State = { error: Error | null };
export class RemoteBoundary extends Component<Props, State> { state: State = { error: null };
static getDerivedStateFromError(error: Error): State { return { error }; }
componentDidCatch(error: Error) { // In production, forward this to your telemetry so a failing // remote is visible even though the shell survived. console.error(`[shell] remote "${this.props.name}" failed`, error); }
reset = () => this.setState({ error: null });
render() { if (this.state.error) { return ( <div className="remote-fallback" role="alert"> <p>{this.props.name} is unavailable right now.</p> <button onClick={this.reset}>Try again</button> </div> ); } return this.props.children; }}2. apps/shell/src/mountCatalog.tsx
หัวข้อที่มีชื่อว่า “2. apps/shell/src/mountCatalog.tsx”ครอบ federated catalog remote ไว้ใน boundary และ <Suspense> boundary รับผิดชอบสถานะ failure; Suspense รับผิดชอบสถานะ loading
import { lazy, Suspense } from 'react';import { RemoteBoundary } from './RemoteBoundary';
// If remoteEntry.js can't be fetched, this import() rejects.const Catalog = lazy(() => import('catalog/Catalog'));
export function MountCatalog() { return ( <RemoteBoundary name="Catalog"> <Suspense fallback={<div className="remote-loading">Loading catalog…</div>}> <Catalog /> </Suspense> </RemoteBoundary> );}ลำดับสำคัญ: RemoteBoundary ต้องอยู่ นอก Suspense lazy import ที่ reject จะ throw ตอน render และมีแค่ boundary ที่อยู่เหนือ component ที่ throw เท่านั้นที่ดักได้
3. apps/shell/src/mountCart.tsx
หัวข้อที่มีชื่อว่า “3. apps/shell/src/mountCart.tsx”Svelte cart remote mount ผ่าน custom element รูปแบบ failure จึงต่างออกไป: import('cart/register') reject ได้ก่อนที่จะ define <cart-app> ได้ด้วยซ้ำ ให้ guard ที่ตัว registration ไม่ใช่ที่ React component
import { Component, type ReactNode } from 'react';import { RemoteBoundary } from './RemoteBoundary';
// Imperatively load + register the custom element, surfacing failure to React.class CartLoader extends Component<{ children: ReactNode }, { ready: boolean; error: Error | null }> { state = { ready: false, error: null as Error | null };
async componentDidMount() { try { await import('cart/register'); // defines <cart-app> this.setState({ ready: true }); } catch (error) { // Re-throw on the next render so RemoteBoundary catches it. this.setState(() => { throw error as Error; }); } }
render() { return this.state.ready ? this.props.children : <div className="remote-loading">Loading cart…</div>; }}
export function MountCart() { return ( <RemoteBoundary name="Cart"> <CartLoader> {/* Custom element — the React types accept it as an intrinsic tag. */} <cart-app></cart-app> </CartLoader> </RemoteBoundary> );}เพราะการ reject ของ dynamic import() ตกอยู่ใน promise ไม่ใช่ใน render ของ React เราจึง re-throw ออกมาจาก state updater เพื่อให้ boundary มองเห็น นั่นคือ seam เดียวที่ failure ตอน load ต้องการการสะกิดเพื่อให้ไปถึง boundary
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”รัน shell กับ remote จริง จากนั้นเอา remote ตัวหนึ่ง offline แล้วยืนยันว่า shell ยังรอด
# Terminal 1 — shell only (do NOT start the catalog remote)pnpm --filter shell dev# ➜ Local: http://localhost:5000/เปิด http://localhost:5000/ เพราะไม่มีอะไร serve http://localhost:5001/remoteEntry.js อยู่ คาดว่า:
- พื้นที่ Catalog แสดง “Catalog is unavailable right now.” พร้อมปุ่ม Try again
- top-nav, session, และ cart badge ยัง render อยู่และโต้ตอบได้ — หน้าไม่ได้จอเปล่า
- console แสดงบรรทัด
[shell] remote "Catalog" failedบรรทัดเดียว ไม่ใช่ React tree crash ที่ไม่ถูกจัดการ
ทีนี้เริ่ม remote แล้ว retry โดยไม่ reload shell:
# Terminal 2pnpm --filter catalog dev# ➜ Local: http://localhost:5001/กด Try again ใน panel ของ Catalog boundary reset, lazy import รันใหม่, และ catalog grid mount ปิดท้ายด้วย production build เพื่อยืนยันว่า boundary compile ได้สะอาด:
pnpm --filter shell build# ✓ built in … — no type errors from RemoteBoundary / MountCartตรวจสอบความเข้าใจ:
<Suspense>โชว์ fallback ระหว่าง remote โหลดอยู่แล้ว ทำไมจึงไม่ครอบคลุมกรณีremoteEntry.jsตอบ 404?- ทำไม
RemoteBoundaryต้องอยู่ นอก<Suspense>แทนที่จะอยู่ข้างใน? - Svelte cart โหลดผ่าน
import('cart/register')ไม่ใช่React.lazyทำไม failure ของ cart จึงต้อง re-throw จาก state updater เพื่อให้ไปถึง boundary? - ความต่างของ “blast radius” ระหว่าง one boundary per remote กับ boundary เดียวครอบทั้ง shell คืออะไร?
คุณครอบ remote แต่ละตัวไว้ใน error boundary เพื่อว่า remoteEntry.js ที่ล้มเหลวจะทำให้ slice เดียว degrade แทนที่จะทำให้ shell จอเปล่า — คือ isolation ที่ทำให้ runtime composition ปลอดภัย คุณจัดการทั้ง remote ที่เป็น React component (catalog) และ remote ที่เป็น custom element (cart) รวมถึง seam ของ rejection ตอน load ที่ต้อง re-throw แบบชัดเจน
ต่อไป เราจะทำให้ composition เร็ว พอ ๆ กับที่ปลอดภัย: Lazy loading & dedupe →