|  | 
|  | 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. | 
0 commit comments