Skip to content

Commit a28e2fb

Browse files
committed
fix(vite): Prevent stale directive map with startup pre-scan (#816)
### Problem The directive scan process builds its map of `"use client"` and `"use server"` files by traversing the dependency graph from the application's entry points. This created an issue where the map would become stale if a code change introduced a new dependency path to a file that was not previously reachable. For example, if a server component was modified to import a client component that had not been imported anywhere else, the Hot Module Replacement (HMR) update would not trigger a re-scan. The server would then fail during server-side rendering (SSR) with a "No module found" error because the newly imported client component was not in its directive map. Worth noting: we do indeed already account for _newly written_ "use client" components in the HMR case. The case described above is instead about "use client" components that did already exist in the codebase, but had not yet been imported anywhere in the application code evaluated at runtime (i.e. it was until then "dead code'). ### Solution This change introduces a pre-scan phase that runs before the dependency-graph traversal. It uses a glob pattern to find all files within the `src` directory that could potentially contain directives, based on their file extensions. These files are then added to the list of entry points for the main directive scan. This ensures that all directive-containing files are discovered at startup, regardless of whether they are immediately reachable from an entry point. This approach makes the directive map aware of all potential directive modules from the beginning, preventing stale map issues during HMR updates. Caching is used to avoid redundant file reads during this process.
1 parent b04ff66 commit a28e2fb

31 files changed

+10149
-87
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Fix Directive Scan Stale Map Issue
2+
3+
**Date**: 2025-01-06
4+
5+
## Problem Definition & Goal
6+
7+
The directive scan was becoming stale when new dependency paths were introduced that weren't part of the initial scan. This caused SSR errors like:
8+
9+
```
10+
Internal server error: (ssr) No module found for '/src/app/pages/todos/Todos.tsx' in module lookup for "use client" directive
11+
```
12+
13+
The root cause was that the initial scan only looked at files reachable from entry points, but when new imports were added that created new dependency paths to directive-containing files, the HMR update didn't trigger a re-scan.
14+
15+
The goal was to implement a solution that would "future-proof" the directive scan against subsequent code changes that introduce new dependency paths to directive-containing files.
16+
17+
## Investigation: Understanding the Root Cause
18+
19+
The directive scan operates by traversing the dependency graph starting from entry points (like `worker.tsx`). It builds a map of all files containing `"use client"` or `"use server"` directives that are reachable from these entry points.
20+
21+
However, this approach has a fundamental limitation: if a directive-containing file exists but isn't currently imported anywhere, it won't be discovered during the initial scan. When a developer later adds an import that creates a path to this previously unreachable file, the HMR update doesn't trigger a re-scan, leading to the "No module found" error.
22+
23+
This is particularly problematic in scenarios where:
24+
1. A server component exists initially
25+
2. A client component exists but isn't imported anywhere
26+
3. The server component is later modified to import the client component
27+
4. The directive scan map is now stale and doesn't include the client component
28+
29+
## Attempt 1: Implementing `findDirectiveRoots` with `path.join`
30+
31+
The first approach was to implement a pre-scan function that would find all directive-containing files in the `src` directory using glob patterns, regardless of whether they're currently reachable from entry points.
32+
33+
**Implementation:**
34+
- Created `findDirectiveRoots` function using `glob` library
35+
- Used `path.join` to construct the `cwd` for the glob search
36+
- Scanned for `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.mdx` files
37+
- Combined pre-scanned files with original entry points
38+
39+
**Result:** The implementation failed. Debug logs showed that the glob search was returning an empty array of files, even in a stable configuration. This was the "smoking gun," indicating the problem was with the glob pattern or its options, not the overall strategy.
40+
41+
## Attempt 2: Fixing Glob Configuration with `path.resolve`
42+
43+
A search of the git history for previous `glob` implementations surfaced an older, working version in commit `c30a8119`. Comparing the two revealed the likely issue: my implementation used `path.join` to construct the `cwd` (current working directory) for the glob, whereas the older, successful implementation used `path.resolve`.
44+
45+
**The Fix:**
46+
- Changed from `path.join(root, "src")` to `path.resolve(root, "src")`
47+
- The `glob` library can be sensitive to how its `cwd` is specified, and `path.resolve` provides a more robust, absolute path
48+
49+
**Result:** Using `path.resolve` for the `cwd` in the glob search immediately fixed the pre-scan, which now correctly identifies all directive-containing files on startup.
50+
51+
## Attempt 3: Adding Caching and Performance Optimizations
52+
53+
With the basic pre-scan working, the next step was to optimize performance and add proper caching to avoid redundant file reads and directive checks.
54+
55+
**Implementation:**
56+
- Added `fileContentCache` to avoid re-reading files during the scan
57+
- Added `directiveCheckCache` to memoize the result of checking a file for directives
58+
- Added error handling for file read failures during pre-scan
59+
- Used `crypto.randomUUID()` for unique key generation
60+
61+
**Result:** The implementation was successful. The pre-scan now correctly identifies all directive-containing files on startup, and advancing to Step 4 of the demo no longer produces the "(ssr) No module found" error.
62+
63+
## Final Solution: Pre-Scan with Combined Entry Points
64+
65+
The final implementation combines the original entry points with pre-scanned directive files:
66+
67+
1. **Pre-Scan Phase**: Uses `glob` to find all potential directive files in `src/` directory
68+
2. **Combined Entry Points**: Merges original entry points with pre-scanned directive files
69+
3. **Caching**: Avoids redundant file reads and directive checks
70+
4. **Error Handling**: Gracefully handles file read errors during pre-scan
71+
72+
**Key Technical Details:**
73+
- Uses `path.resolve` for robust absolute path handling
74+
- Scans for `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.mdx` files
75+
- Caches directive check results to improve performance
76+
- Gracefully handles file read errors during pre-scan
77+
78+
## Status
79+
80+
**Implemented and tested** - Fixes the stale directive map issue that was causing SSR failures when new client components were introduced.
81+
82+
## Current Implementation Status
83+
84+
**Files Modified:**
85+
- `sdk/src/vite/runDirectivesScan.mts` - Added `findDirectiveRoots` function with glob pre-scan
86+
- `sdk/package.json` - Added `glob` and `@types/glob` dependencies
87+
- `playground/missing-link-directive-scan/` - Created playground example
88+
89+
**Key Changes:**
90+
1. Added `findDirectiveRoots` function that scans all files in `src/` directory using glob patterns
91+
2. Combined pre-scanned directive files with original entry points for esbuild scan
92+
3. Added caching with `fileContentCache` and `directiveCheckCache` for performance
93+
4. Used `path.resolve` instead of `path.join` for robust absolute path handling
94+
5. Created playground example demonstrating the "missing link" scenario
95+
96+
**Playground Example Structure:**
97+
- `ComponentA.tsx` - Server component (initially doesn't import client components)
98+
- `ComponentB.tsx` - Server component that imports `ComponentA`
99+
- `ComponentC.tsx` - Client component with "use client" directive
100+
- `MissingLinkPage.tsx` - Page that imports `ComponentA`
101+
- `worker.tsx` - Routes `/missing-link` to `MissingLinkPage`
102+
103+
**Test Scenario:**
104+
1. Start dev server - works fine initially
105+
2. Visit `/missing-link` - renders `ComponentA` (server component)
106+
3. Modify `ComponentA.tsx` to uncomment `<ComponentB />` import
107+
4. Refresh page - should now show `ComponentB` and `ComponentC` without SSR errors
108+
109+
## Next Steps
110+
111+
- [x] Create playground example that reproduces the "missing link" scenario
112+
- [x] Add end-to-end tests to verify the fix works correctly
113+
- [x] Document the solution in architecture docs
114+
- [x] Test the playground example to ensure it reproduces the issue correctly
115+
116+
## Attempt 4: E2E Validation and Documentation
117+
118+
With the core implementation fixing the stale map issue, the next step was to create a safety net for regressions and document the architectural change.
119+
120+
**Implementation:**
121+
- An end-to-end test was added to the `missing-link-directive-scan` playground. This test simulates the exact user workflow that triggered the original bug:
122+
1. It first navigates to the test page and confirms only the initial server component (`ComponentA`) is rendered.
123+
2. It then uses `fs` to programmatically edit `ComponentA.tsx` on disk, uncommenting the import for the client `ComponentB`.
124+
3. After reloading the page, it asserts that `ComponentB` and its child `ComponentC` are now rendered correctly, and critically, that no SSR "module not found" errors appear in the content.
125+
4. Finally, it verifies that client-side interactivity on `ComponentC` is functional.
126+
- The architecture document `directiveScanningAndResolution.md` was updated with a new section, "The Stale Map Problem: Future-Proofing Directive Discovery." This section explains the original limitation of entry-point-only scanning and details the `glob`-based pre-scan solution.
127+
128+
**Result:**
129+
The e2e test was executed. While the test is correctly structured to validate the fix, it is currently failing due to a test-environment-specific module resolution error (`Cannot find module '@/app/pages/MissingLinkPage'`). The test runner seems unable to resolve the `@/` alias. Per instructions, this test failure is being ignored for now, to be fixed manually. The core implementation is considered complete and documented.
130+
131+
## Attempt 5: Test Environment Investigation
132+
133+
After manual verification confirmed the fix works correctly in a normal dev environment, investigation turned to why the e2e test environment fails differently.
134+
135+
**Key Discovery:**
136+
Manual testing in `playground/directives` with `pnpm dev` shows the fix working perfectly - ComponentB and ComponentC render correctly after uncommenting the import, with no SSR errors.
137+
138+
**Test Environment Analysis:**
139+
The e2e test consistently fails with "Polling timed out" and shows the same SSR error in the HTML payload:
140+
```
141+
Internal server error: (ssr) No module found for '/src/components/ComponentB.tsx' in module lookup for "use client" directive
142+
```
143+
144+
**Critical Finding:**
145+
Debug logs with `DEBUG='rwsdk:vite:run-directives-scan'` show no directive scan activity in the test environment, indicating the directive scan is not running at all during test execution. This suggests the test harness is using a different Vite configuration or server instance that bypasses the directive scan entirely.
146+
147+
**Conclusion:**
148+
The implementation is correct and working as intended. The test failure is due to the test environment not executing the directive scan, not a problem with the fix itself.
149+
150+
## Attempt 6: Final E2E Test Debugging
151+
152+
After confirming the core fix was working through manual testing, the focus shifted to fixing the end-to-end test. The investigation revealed several layers of issues that were masking each other.
153+
154+
**Finding 1: False Negative from Instructional Text**
155+
156+
The initial test failures pointed to an SSR error: `No module found for '/src/components/ComponentB.tsx'`. However, deeper inspection of the test output and the `MissingLinkPage.tsx` component revealed that this error message was not from a live SSR failure. It was hardcoded into the component's JSX as an instructional example for developers, explaining what *would* happen without the fix. The test's check for "Internal server error" was detecting this static text and incorrectly flagging it as a failure.
157+
158+
**Solution:** The instructional text in `MissingLinkPage.tsx` was rephrased to describe the error without including the verbatim error message. This resolved the false negative.
159+
160+
**Finding 2: Incorrect File Modification**
161+
162+
With the false negative fixed, the test still timed out. Analysis of the temporary test directory showed that while the `import` statement for `ComponentB` was being correctly uncommented in `ComponentA.tsx`, the JSX component usage (`<ComponentB />`) remained commented out. The test was using a simple string replacement (`.replace('// <ComponentB />', '<ComponentB />')`) which did not match the actual JSX comment syntax in the file (`{/* <ComponentB /> */}`).
163+
164+
**Solution:** The string replacement logic in the test was updated to correctly target and replace the JSX comment, ensuring the `<ComponentB />` element was actually rendered.
165+
166+
**Finding 3: Fragile Selector Logic**
167+
168+
The final issue was intermittent timeouts when the test tried to interact with the client-side counter button in `ComponentC`. The test was using `page.waitForSelector()` which could be brittle.
169+
170+
**Solution:** The interaction logic was refactored to match conventions used in other e2e tests. It now uses `page.evaluate()` to run `document.querySelector()` directly in the browser context, wrapped within a `poll` utility. This provides a more robust way to find and interact with elements after they become available on the page.
171+
172+
With these fixes, the end-to-end test now passes reliably, confirming the directive scan fix and providing a regression test for the future.
173+
174+
## PR Description
175+
176+
**Title:** `fix(vite): Prevent stale directive map with startup pre-scan`
177+
178+
### Problem
179+
180+
The directive scan process builds its map of `"use client"` and `"use server"` files by traversing the dependency graph from the application's entry points. This created an issue where the map would become stale if a code change introduced a new dependency path to a file that was not previously reachable.
181+
182+
For example, if a server component was modified to import a client component that had not been imported anywhere else, the Hot Module Replacement (HMR) update would not trigger a re-scan. The server would then fail during server-side rendering (SSR) with a "No module found" error because the newly imported client component was not in its directive map.
183+
184+
### Solution
185+
186+
This change introduces a pre-scan phase that runs before the dependency-graph traversal. It uses a glob pattern to find all files within the `src` directory that could potentially contain directives, based on their file extensions.
187+
188+
These files are then added to the list of entry points for the main directive scan. This ensures that all directive-containing files are discovered at startup, regardless of whether they are immediately reachable from an entry point. This approach makes the directive map aware of all potential directive modules from the beginning, preventing stale map issues during HMR updates. Caching is used to avoid redundant file reads during this process.
File renamed without changes.

docs/architecture/directiveScanningAndResolution.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,32 @@
22

33
This document details the internal `esbuild`-based scanner used to discover `"use client"` and `"use server"` directives, and the Vite-aware module resolution it employs.
44

5-
## The Challenge: Pre-Optimization Discovery
5+
## The Challenge: A Comprehensive and Correct Pre-Scan
66

7-
A core requirement of the framework is to know the location of all directive-marked modules *before* Vite's main processing begins.
7+
A core requirement of the framework is to know the location of all directive-marked modules *before* Vite's main processing begins. This is necessary in development for Vite's dependency optimizer (`optimizeDeps`) and in production for effective tree-shaking. Because Vite lacks a public API hook at this specific lifecycle point, a custom scanning solution is required.
88

9-
- In **development**, this list is needed before Vite's dependency optimizer (`optimizeDeps`) runs, so that the discovered modules can be correctly pre-bundled.
10-
- In **production**, this list is needed before the initial `worker` build so it can be filtered down to only the modules that are actually used, enabling effective tree-shaking.
9+
A naive scan starting from the application's entry points is insufficient for two key reasons:
1110

12-
Vite does not provide a stable, public API hook at the precise lifecycle point required—after the server and environments are fully configured, but before dependency optimization or the build process begins. This necessitates a custom scanning solution that runs ahead of Vite's own machinery.
11+
1. **It cannot handle conditional exports.** A scan starting from a server entry point would use server-side resolution conditions for the entire dependency graph. When it crosses a `"use client"` boundary, it would fail to switch to browser-side conditions, leading to incorrect module resolution and build failures.
12+
2. **It misses undiscovered modules.** If a directive-containing file exists in the project but is not yet imported by any other file in the graph, an entry-point-based scan will not find it. If a developer later adds an import to that file, the directive map becomes stale, causing "module not found" errors during Server-Side Rendering (SSR).
1313

14-
## The Solution: A Context-Aware `esbuild` Scanner
14+
## The Solution: A Two-Phase, Context-Aware Scan
1515

16-
We implement a standalone scan using `esbuild` for its high-performance traversal of the dependency graph. The key to making this scan accurate is a custom, Vite-aware module resolver that can adapt its behavior based on the context of the code it is traversing.
16+
To address these challenges, the framework implements a two-phase scan that is both comprehensive and contextually aware.
1717

18-
### The Challenge of Conditional Exports
18+
### Phase 1: Glob-based Pre-Scan for All Potential Modules
1919

20-
A static resolver that uses a single environment configuration for the entire scan is insufficient. Modern packages often use conditional exports in their `package.json` to provide different modules for different environments (e.g., a "browser" version vs. a "react-server" version).
20+
The first phase solves the "stale map" problem by finding all files in the application's codebase that could *potentially* contain a directive, regardless of whether they are currently imported.
2121

22-
A static scanner starting from a server entry point would use "worker" conditions for all resolutions. When it encounters a `"use client"` directive and traverses into client-side code, it would continue to use those same server conditions, incorrectly resolving client packages to their server-side counterparts and causing build failures.
22+
- A fast `glob` search is performed across the `src/` directory for all relevant file extensions (`.ts`, `.tsx`, `.js`, `.mdx`, etc.).
23+
- This initial list of files is then filtered down to only those that actually contain a `"use client"` or `"use server"` directive.
24+
- This process ensures that even currently-unimported modules are identified upfront, "future-proofing" the directive map against code changes made during a development session. Caching is used to optimize performance.
2325

24-
### Stateful, Dynamic Resolution
26+
### Phase 2: Context-Aware `esbuild` Traversal
2527

26-
To solve this, the scanner's resolver is stateful. It maintains the current environment context (`'worker'` or `'client'`) as it walks the dependency graph.
28+
The second phase solves the module resolution problem by using `esbuild` to traverse the dependency graph with a stateful, Vite-aware resolver.
29+
30+
The entry points for this phase are a combination of the application's main entry points and the set of directive-containing files discovered in Phase 1. As the scanner traverses the graph, its resolver maintains the current environment context (`'worker'` or `'client'`).
2731

2832
When resolving an import, the process is as follows:
2933
1. Before resolving the import, the scanner inspects the *importing* module for a `"use client"` or `"use server"` directive.
@@ -32,7 +36,7 @@ When resolving an import, the process is as follows:
3236
* A **client resolver**, configured with browser-side conditions (e.g., `"browser"`, `"module"`).
3337
3. The selected resolver is then used to find the requested module, ensuring the correct conditional exports are used. This resolution process is still fully integrated with Vite's plugin ecosystem, allowing user-configured aliases and paths to work seamlessly in both contexts.
3438

35-
This stateful approach allows the scan to be context-aware, dynamically switching its resolution strategy as it crosses the boundaries defined by directives. It correctly mirrors the runtime behavior of the application, resulting in a reliable and accurate scan.
39+
This two-phase approach—combining a comprehensive glob pre-scan with a context-aware `esbuild` traversal—results in a reliable and accurate scan that is resilient to both complex package structures and mid-session code changes.
3640

3741
## Rationale and Alternatives
3842

0 commit comments

Comments
 (0)