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 หน้าเองถึงจะหลุดจาก fallback ที่ค้างอยู่ — แต่อย่างที่ section ตรวจสอบผลข้างล่างแสดง import ของ JS ที่ตายแล้วต้อง 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”ตั้งแต่ Web Components Interop catalog mount ผ่าน custom-element boundary เดียวกับ cart แล้ว — App.tsx import catalog/register เพื่อ side effect แล้ว render <catalog-app> ไม่ใช่ React.lazy(() => import('catalog/Catalog')) อีกต่อไป ดังนั้น failure mode จึงเป็นแบบข้างล่างนี้: import('catalog/register') reject ได้ก่อนที่จะ define <catalog-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 CatalogLoader extends Component<{ children: ReactNode }, { ready: boolean; error: Error | null }> { state = { ready: false, error: null as Error | null };
async componentDidMount() { try { await import('catalog/register'); // defines <catalog-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 catalog…</div>; }}
export function MountCatalog() { return ( <RemoteBoundary name="Catalog"> <CatalogLoader> {/* Custom element — the React types accept it as an intrinsic tag. */} <catalog-app></catalog-app> </CatalogLoader> </RemoteBoundary> );}เพราะการ reject ของ dynamic import() ตกอยู่ใน promise ไม่ใช่ใน render ของ React เราจึง re-throw ออกมาจาก state updater เพื่อให้ RemoteBoundary มองเห็น — .then(() => setReady(true)) เฉย ๆ แบบไม่มี .catch (แบบที่ CatalogRoute.tsx ใช้ก่อนโมดูลนี้) จะค้างอยู่ที่ “Loading…” ตลอดไป เพราะ rejection ไม่มีทางไปถึง render cycle ของ React เลย
3. apps/shell/src/mountCart.tsx
หัวข้อที่มีชื่อว่า “3. apps/shell/src/mountCart.tsx”cart remote mount แบบเดียวกันเป๊ะ — custom element หลัง registrar import — ดังนั้น CartLoader ก็คือ CatalogLoader สลับชื่อ สอง copy เล็ก ๆ ที่ explicit แบบนี้ดีกว่า loader แบบ generic ที่ parameterize ด้วย module id: plugin ของ Module Federation จำ federated import() จาก string specifier ตรง ๆ ถ้าใช้ specifier แบบ dynamic มีความเสี่ยงจะพังกลไกนั้น
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> );}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”รัน 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 กลับขึ้นมา:
# Terminal 2pnpm --filter catalog dev# ➜ Local: http://localhost:5001/กด Try again ใน panel ของ Catalog — boundary reset แล้ว loader รัน import('catalog/register') ใหม่ แต่ browser’s module loader cache “rejection ของ import นั้นเป๊ะ ๆ” ไว้แล้ว คลิกแรกนี้จึงพังซ้ำด้วยข้อความเดิม นั่นเป็นเรื่องคาดหมายได้ ไม่ใช่ bug ของ RemoteBoundary: dynamic import ที่ reject แล้วถูก JS engine cache ไว้ตลอดไป เหมือน script tag ที่ 404 ไม่มีทาง retry ตัวเองได้ Reload หน้า — page load ใหม่จะ evaluate import ใหม่ทั้งหมดกับ remote ที่รันอยู่แล้ว — แล้ว catalog grid จะ mount ใน production นี่คือเหตุผลที่ Module Federation runtime plugin อย่าง retry plugin มีอยู่: มัน retry fetch จริง ๆ ไม่ใช่ import() ที่ cache ไว้ ทำให้ failure ชั่วคราวฟื้นได้โดยไม่ต้อง reload ปิดท้ายด้วย production build เพื่อยืนยันว่า boundary compile ได้สะอาด:
pnpm --filter shell build# ✓ built in … — no type errors from RemoteBoundary / MountCartตรวจสอบความเข้าใจ:
CatalogLoaderกับCartLoaderต่างก็ดัก load failure ด้วยtry/catchรอบawait import(...)ทำไมcatchblock เฉย ๆ ยังไม่พอที่จะโชว์ fallback — ทำไมต้องมีthis.setState(() => { throw error; })เพิ่มเข้ามาด้วย?CatalogRoute.tsx(จาก Module 5) ใช้.then(() => setReady(true))เฉย ๆ แบบไม่มี.catchถ้า remote โหลดไม่ผ่านตอน mount แบบนั้น ผู้ใช้จะเห็นอะไร และทำไม?- ตอนนี้ทั้งสอง remote โหลดผ่าน
import('.../register')ไม่ใช่React.lazyทำไม failure จึงต้อง 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 mount ผ่าน custom-element loader แบบเดียวกัน รวมถึง seam ของ rejection ตอน load ที่ต้อง re-throw แบบชัดเจนเพื่อให้ไปถึง RemoteBoundary
ต่อไป เราจะทำให้ composition เร็ว พอ ๆ กับที่ปลอดภัย: Lazy loading & dedupe →