ข้ามไปยังเนื้อหา

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 ตัวเองอยู่ดี

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;
}
}

ตั้งแต่ 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 เลย

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 window
# 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 window
# Terminal 2
pnpm --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 ได้สะอาด:

Terminal window
pnpm --filter shell build
# ✓ built in … — no type errors from RemoteBoundary / MountCart

ตรวจสอบความเข้าใจ:

  1. CatalogLoader กับ CartLoader ต่างก็ดัก load failure ด้วย try/catch รอบ await import(...) ทำไม catch block เฉย ๆ ยังไม่พอที่จะโชว์ fallback — ทำไมต้องมี this.setState(() => { throw error; }) เพิ่มเข้ามาด้วย?
  2. CatalogRoute.tsx (จาก Module 5) ใช้ .then(() => setReady(true)) เฉย ๆ แบบไม่มี .catch ถ้า remote โหลดไม่ผ่านตอน mount แบบนั้น ผู้ใช้จะเห็นอะไร และทำไม?
  3. ตอนนี้ทั้งสอง remote โหลดผ่าน import('.../register') ไม่ใช่ React.lazy ทำไม failure จึงต้อง re-throw จาก state updater เพื่อให้ไปถึง boundary?
  4. ความต่างของ “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 →