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

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 ทั้งหน้า

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

ครอบ 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 เท่านั้นที่ดักได้

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 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 แล้ว retry โดยไม่ reload shell:

Terminal window
# Terminal 2
pnpm --filter catalog dev
# ➜ Local: http://localhost:5001/

กด Try again ใน panel ของ Catalog boundary reset, lazy import รันใหม่, และ catalog grid mount ปิดท้ายด้วย production build เพื่อยืนยันว่า boundary compile ได้สะอาด:

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

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

  1. <Suspense> โชว์ fallback ระหว่าง remote โหลดอยู่แล้ว ทำไมจึงไม่ครอบคลุมกรณี remoteEntry.js ตอบ 404?
  2. ทำไม RemoteBoundary ต้องอยู่ นอก <Suspense> แทนที่จะอยู่ข้างใน?
  3. Svelte cart โหลดผ่าน import('cart/register') ไม่ใช่ React.lazy ทำไม failure ของ cart จึงต้อง 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 ที่เป็น React component (catalog) และ remote ที่เป็น custom element (cart) รวมถึง seam ของ rejection ตอน load ที่ต้อง re-throw แบบชัดเจน

ต่อไป เราจะทำให้ composition เร็ว พอ ๆ กับที่ปลอดภัย: Lazy loading & dedupe →