common source of post-migration rendering bugs.
Core Solution
The optimization pipeline operates in four distinct phases: scope isolation, mutability classification, dependency graph generation, and hook mapping. Each phase transforms the original Vue 3 <script setup> code into a React component that adheres to strict reference equality rules.
Phase 1: AST Parsing & Scope Isolation
The compiler first parses the <script setup> block into an Abstract Syntax Tree. It isolates top-level declarations from template bindings and lifecycle hooks. This separation is critical because React components are pure functions that execute on every render. Any value declared inside the function body is recreated unless explicitly hoisted or memoized.
Phase 2: Static vs Reactive Classification
The compiler evaluates each top-level const initializer. It performs a lightweight type-checking pass to determine whether the value is:
- A primitive literal (
string, number, boolean, null, undefined)
- A frozen or deeply constant object/array
- An expression that references Vue 3's
ref() or reactive() proxies
Primitive literals and constant structures are flagged for hoisting. Expressions containing reactive property access are flagged for memoization.
Phase 3: Dependency Graph Generation
For reactive expressions, the compiler traverses the AST to extract every reactive property access chain. It flattens nested paths (e.g., config.network.timeout) into a linear dependency array. This step eliminates the manual tracing developers would otherwise perform, ensuring that useMemo invalidates exactly when the underlying reactive state changes.
Phase 4: React Hook Mapping
Hoisted values are emitted outside the component function. Reactive expressions are wrapped in useMemo() with the generated dependency array. The compiler also injects necessary accessor transformations to bridge Vue's auto-unwrapping behavior with React's explicit .value or proxy access patterns.
Transpilation Example: Task Queue Manager
Original Vue 3 Component
<script setup lang="ts">
const MAX_BATCH_SIZE = 5;
const RETRY_DELAY_MS = 2000;
const queueState = reactive({
pending: [] as string[],
processing: false,
config: {
timeout: 30000,
verbose: true
}
});
const estimatedWaitTime = queueState.pending.length * RETRY_DELAY_MS;
const shouldProcess = queueState.pending.length >= MAX_BATCH_SIZE && !queueState.processing;
</script>
Compiled React Output
import { useMemo } from 'react';
import { useReactive } from '@vureact/runtime';
// Hoisted static configuration
const MAX_BATCH_SIZE = 5;
const RETRY_DELAY_MS = 2000;
export function TaskQueueManager() {
const queueState = useReactive({
pending: [] as string[],
processing: false,
config: {
timeout: 30000,
verbose: true
}
});
// Auto-memoized with traced dependencies
const estimatedWaitTime = useMemo(
() => queueState.pending.length * RETRY_DELAY_MS,
[queueState.pending.length]
);
const shouldProcess = useMemo(
() => queueState.pending.length >= MAX_BATCH_SIZE && !queueState.processing,
[queueState.pending.length, queueState.processing]
);
return (
<div>
<p>Estimated wait: {estimatedWaitTime}ms</p>
<button disabled={!shouldProcess}>Process Batch</button>
</div>
);
}
Architecture Rationale
- Hoisting Primitives:
MAX_BATCH_SIZE and RETRY_DELAY_MS are lifted outside the component. This guarantees they occupy a single memory reference across the entire module lifecycle, eliminating recreation overhead.
- Selective Memoization:
estimatedWaitTime and shouldProcess are wrapped in useMemo because they depend on reactive state. The compiler generates exact dependency arrays, preventing unnecessary recalculations while ensuring updates trigger when pending.length or processing changes.
- Why Not Wrap Everything?: Pure static objects or expressions that don't reference reactive proxies are left as-is. Wrapping them in
useMemo would introduce unnecessary hook overhead and complicate the dependency graph without performance benefits.
Pitfall Guide
Migrating reactivity models requires careful handling of reference semantics. Below are the most common failure points observed during production transpilation, along with proven mitigation strategies.
1. Hoisting Mutable Objects with Functions
Explanation: Developers sometimes hoist objects that contain method definitions or reactive references. In React, hoisted objects are created once at module load time. If those objects contain functions that close over component state, they will capture stale references.
Fix: Restrict hoisting to primitives, frozen objects, or pure utility functions. Any object containing callbacks or reactive state must remain inside the component or be wrapped in useMemo.
2. Over-Wrapping Pure Computations
Explanation: Applying useMemo to simple arithmetic or string concatenation that doesn't depend on reactive state adds hook overhead. React's memoization has a cost: it stores previous values and compares dependencies on every render.
Fix: Implement a purity check during compilation. If an expression contains zero reactive property accesses, skip the wrapper entirely. Only memoize when dependency tracking is required.
3. Nested Reactive Path Omission
Explanation: Auto-generated dependency arrays sometimes miss deeply nested property accesses like state.ui.theme.colors.primary. If the compiler only tracks top-level keys, useMemo won't invalidate when nested values change.
Fix: Use recursive AST traversal to flatten all property access chains. Map each unique reactive path to a dependency entry. Validate the generated array against a dry-run render cycle.
4. Callback Reference Instability Inside Memoized Objects
Explanation: When a useMemo block returns an object containing inline arrow functions, those functions are recreated every time the dependency array invalidates. This breaks React.memo optimizations in child components.
Fix: Extract stable callbacks outside the memoized object, or wrap them in useCallback with explicit dependencies. The compiler should detect function expressions inside useMemo returns and apply secondary memoization.
5. Vue ref Unwrapping Mismatch
Explanation: Vue 3 automatically unwraps ref values in templates and <script setup>. React requires explicit .value access. Naive transpilation often forgets to inject .value when a ref is used inside a derived expression, causing undefined errors or stale reads.
Fix: Implement a scope-aware accessor transformer. When a ref is referenced outside of a template binding, automatically append .value. Maintain a symbol table to track which identifiers are reactive proxies.
6. Circular Dependency Traps
Explanation: Auto-analysis can accidentally create circular dependencies in useMemo arrays. For example, const a = useMemo(() => b + 1, [b]) and const b = useMemo(() => a + 1, [a]) will cause infinite render loops or React warnings.
Fix: Run a topological sort on the dependency graph before emitting code. Detect cycles and either break them by lifting one value to a useRef or flagging it for manual review.
7. Ignoring React Strict Mode Double-Rendering
Explanation: React 18's Strict Mode intentionally double-invokes component functions in development to surface side effects. Migrated code that relies on implicit Vue reactivity timing often fails silently or throws hydration mismatches under Strict Mode.
Fix: Run the transpiled codebase through a Strict Mode test suite. Ensure all side effects are isolated in useEffect and that memoized values are truly pure. Treat Strict Mode warnings as compilation errors during migration.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Utility component with fixed thresholds | Compiler hoisting | Eliminates recreation overhead; zero runtime cost | Negative (performance gain) |
| High-frequency update dashboard | Compiler useMemo with auto-deps | Guarantees reference stability; prevents unnecessary child re-renders | Neutral to Positive |
| Complex derived state with cross-module dependencies | Manual useMemo + compiler assist | Auto-analysis may miss external imports; manual control ensures correctness | Positive (higher dev time, lower risk) |
| Legacy Vue 2 Options API migration | Full transpilation pipeline | Vue 2 lacks <script setup> static scope; compiler must reconstruct reactivity boundaries | High (requires refactoring pass) |
Configuration Template
// vureact.config.ts
import { defineConfig } from '@vureact/compiler';
export default defineConfig({
target: 'react-18',
scriptSetup: {
hoisting: {
enabled: true,
maxDepth: 2,
allowFunctions: false,
whitelist: ['/^CONST_/', '/^DEFAULT_/']
},
memoization: {
enabled: true,
autoDependencyAnalysis: true,
flattenNestedPaths: true,
cycleDetection: 'throw',
pureExpressionThreshold: 3 // Skip memoization if expression depth < 3
},
refTransformation: {
unwrapInTemplates: true,
injectValueAccessor: true,
proxyMapping: 'useReactive'
}
},
output: {
format: 'esm',
strictModeValidation: true,
sourceMap: true
}
});
Quick Start Guide
- Install the toolchain: Add
@vureact/compiler and @vureact/runtime to your project dependencies. The runtime package provides the useReactive and useVRef polyfills that bridge Vue's reactivity model to React's hook system.
- Configure the compiler: Create a
vureact.config.ts file using the template above. Adjust the hoisting whitelist and memoization thresholds to match your codebase conventions.
- Run the transformation: Execute
npx vureact transform ./src --out ./src-react. The compiler will parse Vue 3 SFCs, apply static analysis, and emit optimized React components with hoisted constants and auto-memoized derived state.
- Validate and iterate: Open the output directory and review the generated dependency arrays. Run
npm run lint to catch any ESLint violations, then launch the React app in Strict Mode to verify render stability. Address any flagged cycles or stale closure warnings before merging.