@@ -27,6 +27,15 @@ const skipPatterns = [".test.", ".stories.", ".jest."];
2727// Maximum length for truncated error messages in the report.
2828const MAX_ERROR_LENGTH = 120 ;
2929
30+ // Patterns that identify a function/closure value on the RHS of an
31+ // assignment. Primitives (strings, numbers, booleans) are fine without
32+ // memoization because `!==` compares them by value. Only reference types
33+ // (closures, objects, arrays) cause problems.
34+ const CLOSURE_RHS = / ^ \s * (?: c o n s t | l e t ) \s + ( \w + ) \s * = \s * (?: a s y n c \s + ) ? (?: \( [ ^ ) ] * \) \s * = > | \w + \s * = > | f u n c t i o n \s * \( ) / ;
35+
36+ // Matches a `$[N] !== name` fragment inside an `if (...)` guard.
37+ const DEP_CHECK = / \$ \[ \d + \] \s * ! = = \s * ( \w + ) / g;
38+
3039// ---------------------------------------------------------------------------
3140// File collection
3241// ---------------------------------------------------------------------------
@@ -69,8 +78,8 @@ function collectFiles(dir) {
6978//
7079// We use transformSync deliberately. The React Compiler plugin is
7180// CPU-bound (parse-only takes ~2s vs ~19s with the compiler over all
72- // of site/src), so transformAsync + Promise.all gives no speedup —
73- // Node still runs all transforms on a single thread. Benchmarked
81+ // of site/src), so transformAsync + Promise.all gives no speedup
82+ // because Node still runs all transforms on a single thread. Benchmarked
7483// sync, async-sequential, and async-parallel: all land within noise
7584// of each other. The sync API keeps the code simple.
7685// ---------------------------------------------------------------------------
@@ -153,11 +162,13 @@ function compileFile(file) {
153162
154163 return {
155164 compiled : compiledCount ,
165+ code : result ?. code ?? "" ,
156166 diagnostics : deduplicateDiagnostics ( diagnostics ) ,
157167 } ;
158168 } catch ( e ) {
159169 return {
160170 compiled : 0 ,
171+ code : "" ,
161172 diagnostics : [ {
162173 line : 0 ,
163174 // Truncate to keep the one-line report readable.
@@ -167,6 +178,70 @@ function compileFile(file) {
167178 }
168179}
169180
181+ // ---------------------------------------------------------------------------
182+ // Scope-pruning detection
183+ //
184+ // The compiler's flattenScopesWithHooksOrUse pass silently drops
185+ // memoization scopes that span across hook calls. A closure whose
186+ // scope is pruned appears as a bare `const name = (...) =>` with
187+ // no `$[N]` guard, yet it may still be listed as a dependency in a
188+ // downstream JSX memoization block (`$[N] !== name`). That means
189+ // the JSX cache check fails every render because `name` is a new
190+ // function reference each time.
191+ //
192+ // findUnmemoizedClosureDeps detects this pattern in compiled output:
193+ // 1. Collect every name that appears in a `$[N] !== name` dep check.
194+ // 2. For each, check if the name is assigned a function value
195+ // (arrow or function expression) outside any `$[N]` guard.
196+ // 3. If so, the closure is unmemoized but used as a reactive dep,
197+ // which defeats the downstream memoization.
198+ // ---------------------------------------------------------------------------
199+
200+ /**
201+ * Scan compiled output for closures that appear as dependencies in
202+ * memoization guards but are not themselves memoized. Returns an
203+ * array of `{ name, line }` objects for each finding.
204+ */
205+ export function findUnmemoizedClosureDeps ( code ) {
206+ if ( ! code ) return [ ] ;
207+
208+ const lines = code . split ( "\n" ) ;
209+
210+ // Pass 1: collect every name used in a $[N] !== name dep check.
211+ const depNames = new Set ( ) ;
212+ for ( const line of lines ) {
213+ for ( const m of line . matchAll ( DEP_CHECK ) ) {
214+ depNames . add ( m [ 1 ] ) ;
215+ }
216+ }
217+ if ( depNames . size === 0 ) return [ ] ;
218+
219+ // Pass 2: find closure definitions that are directly assigned a
220+ // function value (not assigned from a temp like `const x = t1`).
221+ // A memoized closure uses the temp pattern:
222+ // if ($[N] !== dep) { t1 = () => {...}; } else { t1 = $[N]; }
223+ // const name = t1;
224+ // An unmemoized closure is assigned the function directly:
225+ // const name = () => {...};
226+ const findings = [ ] ;
227+ for ( let i = 0 ; i < lines . length ; i ++ ) {
228+ const match = lines [ i ] . match ( CLOSURE_RHS ) ;
229+ if ( ! match ) continue ;
230+
231+ const name = match [ 1 ] ;
232+ if ( ! depNames . has ( name ) ) continue ;
233+
234+ // Compiler temporaries are named t0, t1, ... tN. If the
235+ // variable name matches that pattern it's an intermediate,
236+ // not a user-visible declaration.
237+ if ( / ^ t \d + $ / . test ( name ) ) continue ;
238+
239+ findings . push ( { name, line : i + 1 } ) ;
240+ }
241+
242+ return findings ;
243+ }
244+
170245// ---------------------------------------------------------------------------
171246// Report
172247// ---------------------------------------------------------------------------
@@ -206,27 +281,47 @@ function printReport(failures, totalCompiled, fileCount, hadErrors) {
206281// Main
207282// ---------------------------------------------------------------------------
208283
284+ // Tracks whether collectFiles encountered a missing directory.
285+ // Module-scoped so the function can set it and the main block can
286+ // read it after collection finishes.
287+ let hadCollectionErrors = false ;
288+
209289// Only run the main block when executed directly, not when imported
210290// by tests for the exported pure functions.
211291if ( process . argv [ 1 ] === fileURLToPath ( import . meta. url ) ) {
212- let hadCollectionErrors = false ;
213292
214293 const files = targetDirs . flatMap ( ( d ) => collectFiles ( join ( siteDir , d ) ) ) ;
215294
216- let totalCompiled = 0 ;
217- const failures = [ ] ;
295+ let totalCompiled = 0 ;
296+ const failures = [ ] ;
218297
219- for ( const file of files ) {
220- const { compiled, diagnostics } = compileFile ( file ) ;
221- totalCompiled += compiled ;
222- if ( diagnostics . length > 0 ) {
223- failures . push ( { file, compiled, diagnostics } ) ;
298+ const scopePruned = [ ] ;
299+
300+ for ( const file of files ) {
301+ const { compiled, code, diagnostics } = compileFile ( file ) ;
302+ totalCompiled += compiled ;
303+ if ( diagnostics . length > 0 ) {
304+ failures . push ( { file, compiled, diagnostics } ) ;
305+ }
306+ const pruned = findUnmemoizedClosureDeps ( code ) ;
307+ if ( pruned . length > 0 ) {
308+ scopePruned . push ( { file, closures : pruned } ) ;
309+ }
224310 }
225- }
226311
227- printReport ( failures , totalCompiled , files . length , hadCollectionErrors ) ;
312+ printReport ( failures , totalCompiled , files . length , hadCollectionErrors ) ;
228313
229- if ( failures . length > 0 || hadCollectionErrors ) {
230- process . exitCode = 1 ;
231- }
314+ if ( scopePruned . length > 0 ) {
315+ console . log ( "\nUnmemoized closures used as reactive dependencies:" ) ;
316+ console . log ( "(Move these after all hook calls to restore memoization)\n" ) ;
317+ for ( const { file, closures } of scopePruned ) {
318+ for ( const c of closures ) {
319+ console . log ( ` ✗ ${ shortPath ( file ) } : ${ c . name } ` ) ;
320+ }
321+ }
322+ }
323+
324+ if ( failures . length > 0 || hadCollectionErrors || scopePruned . length > 0 ) {
325+ process . exitCode = 1 ;
326+ }
232327}
0 commit comments