on, and avoid rewriting complex promise-chaining logic. The adapter acts as a semantic translator, ensuring that timeout thresholds, fallback rendering, and state transitions behave identically across both environments. This eliminates the need for custom hook factories, reduces testing surface area, and allows engineering teams to focus on business logic rather than framework reconciliation.
Core Solution
The compilation pipeline transforms Vue’s <Suspense> template syntax into a React-compatible component by intercepting the AST during the build phase. The compiler identifies <Suspense> nodes, extracts slot definitions, maps Vue-specific attributes to React props, and injects the adapter from @vureact/runtime-core. The resulting JSX maintains Vue’s async semantics while conforming to React’s component model.
Step-by-Step Implementation
- AST Traversal: The compiler scans the Vue template for
<Suspense> boundaries.
- Slot Extraction:
#default and #fallback slots are parsed and converted to React children and props.
- Attribute Mapping: Vue directives (
:timeout, @pending) are transformed into camelCase React props (timeout, onPending).
- Adapter Injection: The compiled output imports
Suspense from @vureact/runtime-core and wraps the extracted children.
- Lifecycle Binding: Event listeners are converted to callback props, ensuring state transitions trigger correctly in React’s render cycle.
New Code Examples
Vue Source Template
<template>
<Suspense :timeout="800" @pending="trackPending" @resolve="finalizeLoad">
<template #default>
<MetricsDashboard />
<UserPreferences />
</template>
<template #fallback>
<LoadingSkeleton type="dashboard" />
</template>
</Suspense>
</template>
Compiled React Output
import { Suspense } from '@vureact/runtime-core';
import { MetricsDashboard } from './MetricsDashboard';
import { UserPreferences } from './UserPreferences';
import { LoadingSkeleton } from './LoadingSkeleton';
export function AsyncBoundaryContainer() {
const handlePending = () => console.log('Boundary entered pending state');
const handleResolve = () => console.log('All dependencies resolved');
return (
<Suspense
timeout={800}
fallback={<LoadingSkeleton type="dashboard" />}
onPending={handlePending}
onResolve={handleResolve}
>
<MetricsDashboard />
<UserPreferences />
</Suspense>
);
}
Architecture Decisions and Rationale
The decision to use an adapter component rather than relying on React’s native Suspense stems from three critical requirements:
- Semantic Parity: Vue’s
<Suspense> exposes explicit lifecycle events that React’s native implementation does not provide. The adapter bridges this gap by wrapping React’s concurrent rendering behavior with Vue-compatible callbacks, ensuring migration teams retain observability into async states.
- Timeout Preservation: React lacks a built-in mechanism to delay fallback rendering. The adapter implements a microtask-based timer that suppresses fallback UI until the specified threshold elapses, preventing layout thrashing on fast network conditions.
- Slot-to-Prop Abstraction: Vue’s template slots are compile-time constructs. The compiler flattens
#default content into direct children and maps #fallback to a fallback prop, aligning with React’s composition model while preserving the original template structure.
Each choice prioritizes migration velocity over framework purity. By maintaining Vue’s async boundary semantics, the adapter reduces cognitive load, eliminates manual promise orchestration, and ensures consistent loading behavior across migrated routes.
Pitfall Guide
1. Omitting the Fallback Prop
Explanation: The adapter requires a fallback prop to render during the pending state. If omitted, the compiler may fail silently or the runtime will throw a hydration mismatch error.
Fix: Always ensure the #fallback slot is defined in the Vue template. The compiler will automatically map it to the fallback prop. Validate compiled output during CI builds.
2. Misconfiguring Timeout Thresholds
Explanation: Setting timeout too low causes unnecessary UI flashing. Setting it too high delays critical feedback, degrading perceived performance.
Fix: Use network-aware thresholds. Default to 300–500ms for local development, and 600–1000ms for production environments with variable latency. Implement adaptive timeout logic based on navigator.connection.effectiveType.
3. Ignoring Error Boundary Integration
Explanation: Vue’s <Suspense> does not catch rendering errors; it only handles async resolution. React’s native Suspense also requires ErrorBoundary wrappers for failure states.
Fix: Wrap the compiled Suspense component with a React error boundary. Map Vue’s onError patterns to React’s componentDidCatch or getDerivedStateFromError to prevent unhandled promise rejections from crashing the tree.
4. Nesting Suspense Boundaries Excessively
Explanation: Deeply nested async boundaries create waterfall loading patterns, where child boundaries block parent resolution, increasing total load time.
Fix: Flatten async dependencies where possible. Use parallel dynamic imports and consolidate fallback UI at the route level. Reserve nested boundaries for isolated, independent feature modules.
5. Lifecycle Callback Scope Confusion
Explanation: Vue emits events synchronously, while React props expect stable function references. Inline arrow functions in compiled output can trigger unnecessary re-renders.
Fix: Memoize callback functions using useCallback in the parent component. Ensure lifecycle handlers do not capture volatile state unless explicitly required.
6. SSR Hydration Mismatches
Explanation: Server-side rendering may resolve async dependencies faster than client-side hydration, causing the fallback to render on the server but not on the client, triggering hydration warnings.
Fix: Disable timeout logic during SSR by checking typeof window === 'undefined'. Use useEffect to hydrate fallback state only after client mount. Align server and client timeout configurations.
7. State Leakage Across Async Boundaries
Explanation: Shared context or global stores may update while a boundary is pending, causing stale fallback UI or inconsistent state when resolution completes.
Fix: Isolate async boundary state using React Context or state containers. Avoid mutating shared stores during pending phases. Implement snapshot-based state restoration on onResolve.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Vue-to-React Migration | Use VuReact Suspense adapter | Preserves Vue async semantics, zero-rewrite migration | Low (compiler handles transformation) |
| Greenfield React Project | Use native React.lazy + Suspense | Aligns with React ecosystem, no adapter overhead | Medium (requires manual timeout/error handling) |
| High-Latency Mobile Users | Adapter with adaptive timeout | Prevents UI flashing, improves perceived performance | Low (network detection is lightweight) |
| SSR-Heavy Applications | Adapter + hydration-safe timeout | Avoids server/client mismatch, maintains consistency | Medium (requires SSR configuration) |
| Complex State Dependencies | Adapter + isolated context | Prevents state leakage during pending phases | High (requires architectural refactoring) |
Configuration Template
// src/components/AsyncBoundary.tsx
import { Suspense } from '@vureact/runtime-core';
import { useEffect, useState, useCallback } from 'react';
interface AsyncBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
timeout?: number;
onPending?: () => void;
onFallback?: () => void;
onResolve?: () => void;
}
export function AsyncBoundary({
children,
fallback,
timeout = 0,
onPending,
onFallback,
onResolve,
}: AsyncBoundaryProps) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const handlePending = useCallback(() => {
onPending?.();
}, [onPending]);
const handleFallback = useCallback(() => {
onFallback?.();
}, [onFallback]);
const handleResolve = useCallback(() => {
onResolve?.();
}, [onResolve]);
// Disable timeout during SSR to prevent hydration mismatches
const effectiveTimeout = isClient ? timeout : 0;
return (
<Suspense
fallback={fallback}
timeout={effectiveTimeout}
onPending={handlePending}
onFallback={handleFallback}
onResolve={handleResolve}
>
{children}
</Suspense>
);
}
Quick Start Guide
- Install the Runtime Package: Add
@vureact/runtime-core to your project dependencies. Ensure your build pipeline includes the VuReact compiler plugin.
- Configure Compiler Options: Enable
suspenseAdapter: true in your vureact.config.ts to automatically transform Vue <Suspense> nodes during compilation.
- Replace Vue Templates: Keep your existing Vue template structure. The compiler will output React-compatible JSX with mapped props and lifecycle callbacks.
- Add Error Boundaries: Wrap the compiled output with a React error boundary to catch rendering failures and prevent tree crashes.
- Validate in Development: Run the application with React strict mode enabled. Verify that
onPending, onFallback, and onResolve fire in the correct sequence and that timeout thresholds behave as expected.