Repository navigation
[Bug]: paired CSS and loader edits can commit the previous server loader #147
Description
Problem
Editing a server loader and CSS ownership in the same parallel dev rebuild can leave requests returning the old loader value after both compilers finish. The committed CSS manifest is new, but the evaluated server build is reused from the previous generation.
Expected: when the controller identifies the new Web and Node compilations as one attempt, commit their new manifest and loader together.
Actual: if Node starts before Web finishes, a CSS-only manifest comparison can select the old server build even though the loader body changed. The reproduced request returns old with /new.css, without a warning.
Tested scope
Reproduced in published 0.7.1 and 0.8.0 controller/coordinator sources. The latest six-case rerun used unmodified 0.8.0 files from a Rsbuild 2.2.8 / Rspack 2.2.6 installation; all 176 shipped Router files matched the published package. These are source-harness results, not compiler/browser results. Hooks and bundle evaluation are mocked; attempt identities come from the real controller.
This requires a source edit. It is distinct from no-edit lazy activation. Reusing the previous server build for a genuinely unpaired CSS-only update is intentional and is included as a control.
Standalone reproduction
Use Node 24.19.0 for its built-in TypeScript stripping API. In an empty directory, download the exact published source; the harness supplies mocked services and needs no package dependencies installed:
curl -fL https://registry.npmjs.org/rsbuild-plugin-react-router/-/rsbuild-plugin-react-router-0.8.0.tgz -o plugin.tgz
tar -xzf plugin.tgz
node repro.cjs ./packageSave the following as repro.cjs. It tests stock and a one-condition in-memory candidate without editing the package. Only the default package path was made portable from the executed harness. The exact script below was also executed against an existing copy of the published 0.8.0 source on Node 22.15.1: all six cases passed. The download commands were not rerun during that packaging check.
Expected output: six rows. Both paired orderings (aggregate hook and explicit invalidation hooks) produce loaderValue: "old" / evaluations: 1 on stock and "new" / 2 with the candidate. Both arms commit /new.css. The unpaired-overlap control produces "old" / 1 in both arms. The script asserts the identities and outcomes and exits nonzero on a mismatch.
Complete source harness
// Executes actual controller, coordinator and identity helpers. All attempt tokens
// are created by controller hooks; this harness never supplies graph identities.
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict'),path=require('node:path');
const {stripTypeScriptTypes}=require('node:module');
const dirs={'0.8.0':path.resolve(process.argv[2] || './package')};
const strip=file=>stripTypeScriptTypes(fs.readFileSync(file,'utf8')).replace(/^import[\s\S]*?;\n/gm,'').replace(/\bexport\s+(?=(?:const|function|class)\b)/g,'');
async function run(version,patched,ordering){
const dir=dirs[version],callbacks={},warnings=[],errors=[],attempts=[];let binding,evaluations=0;
const api={logger:{error:e=>errors.push(e),warn:w=>warnings.push(w)},modifyRsbuildConfig(){},onBeforeStartDevServer:x=>callbacks.start=x.handler,onCloseDevServer(){},onBeforeDevCompile:x=>callbacks.before=x.handler,onAfterCreateCompiler:x=>callbacks.created=x,onAfterDevCompile:x=>callbacks.after=x};
const makeBuild=value=>({routes:{root:{module:{loader:()=>value}}}});
const server={currentBuild:makeBuild('old'),sockWrite(){},environments:{web:{hot:{}}}};
const sessions={getActiveBinding:()=>binding,assertCanStart(){},createBinding:(server,runtime)=>binding={id:Symbol(),server,runtime},bindCloseObservation(){}};
const context={console,structuredClone,Set,Map,WeakMap,WeakSet,Symbol,Object,Array,Error,Promise,Reflect,JSON,Boolean,isAbsolute:path.isAbsolute,relative:path.relative,Date,
setTimeout(){return 1},clearTimeout(){},PLUGIN_NAME:'react-router',DEV_MANIFEST_UPDATE_EVENT:'react-router:manifest-update',
HMR_PATCHABLE_ROUTE_FLAGS:['hasLoader','hasAction','hasClientLoader','hasClientAction','hasClientMiddleware','hasErrorBoundary'],
FiberId:{none:{}},EffectDeferred:{unsafeMake:()=>({}),unsafeDone(){}},Effect:{succeed:x=>x,fail:x=>x},normalizeEffectError:x=>x,
evaluateServerBuilds:async()=>{evaluations++;return{app:server.currentBuild}},createDevRuntimeSessionManager:()=>sessions,createDevHdrChannel:()=>({publish(){},close(){}}),
};
let artifacts=strip(dir+'/src/dev-runtime-artifacts.ts');artifacts=artifacts.slice(0,artifacts.indexOf('const startServerBuildEvaluationEffect'))+artifacts.slice(artifacts.indexOf('const assertBuildMatchesManifest'));
let generation=strip(dir+'/src/dev-generation.ts');if(patched){const needle='(!nodeChanged || identity.nodeWeb !== webIdentity)';assert.equal(generation.split(needle).length,2);generation=generation.replace(needle,'(!nodeChanged || (!identity.attempt && identity.nodeWeb !== webIdentity))');}
vm.runInNewContext([artifacts,generation,strip(dir+'/src/dev-runtime-compilation.ts'),version==='0.8.0'?strip(dir+'/src/dev-hdr-intent.ts'):'',strip(dir+'/src/dev-runtime-controller.ts'),'globalThis.create=createReactRouterDevRuntimeController;'].join('\n'),context);
const controller=context.create({api,isBuild:false,buildPlan:{entryNames:['app'],defaultEntryName:'app'}});
const compiler=name=>{const hooks={};for(const key of ['invalid','done','thisCompilation','afterDone','failed']){const fns=[];hooks[key]={tap:(_name,fn)=>fns.push(fn),call:(...args)=>fns.forEach(fn=>fn(...args))};}return{name,hooks,modifiedFiles:new Set(),removedFiles:new Set()};};
await callbacks.start({server});const web=compiler('web'),node=compiler('node');callbacks.created({compiler:{compilers:[web,node]}});
const realFinish=binding.runtime.finishAttempt.bind(binding.runtime);
binding.runtime.finishAttempt=async(stats,changes,identity)=>{attempts.push({paired:!!identity.attempt,nodeStartedAfterThisWebFinished:identity.nodeWeb===identity.web});return realFinish(stats,changes,identity)};
const compile=compiler=>{const compilation={name:compiler.name,compiler,fileDependencies:new Set(['/root.tsx']),buildDependencies:new Set(),contextDependencies:new Set(),missingDependencies:new Set()};compiler.hooks.thisCompilation.call(compilation);return{hasErrors:()=>false,compilation}};
const complete=stats=>{const compiler=stats.compilation.compiler;compiler.hooks.done.call(stats);compiler.hooks.afterDone.call(stats)};
const manifest=css=>({app:{version:css,entry:{module:'/entry.js',imports:[],css:[]},routes:{root:{id:'root',module:'/root.js',imports:[],css:[css],hasLoader:true}}}});
callbacks.before();const baseWeb=compile(web);controller.captureWeb(baseWeb.compilation,manifest('/old.css'));complete(baseWeb);const baseNode=compile(node);complete(baseNode);
await callbacks.after({stats:{web:baseWeb,node:baseNode}});await new Promise(setImmediate);
assert.equal((await controller.createBuildLoader()()).routes.root.module.loader(),'old');
server.currentBuild=makeBuild('new');web.modifiedFiles=new Set(['/root.tsx']);node.modifiedFiles=new Set(['/root.tsx']);
if(ordering==='paired-with-invalid-hooks'){web.hooks.invalid.call();node.hooks.invalid.call();}
callbacks.before();const nextNode=compile(node);
if(ordering==='unpaired-overlap')web.hooks.invalid.call();
const nextWeb=compile(web);controller.captureWeb(nextWeb.compilation,manifest('/new.css'));complete(nextWeb);complete(nextNode);
await callbacks.after({stats:{web:nextWeb,node:nextNode}});await new Promise(setImmediate);
const committed=await controller.createBuildLoader()();const expected=patched&&ordering!=='unpaired-overlap'?'new':'old';
assert.equal(attempts.length,2);assert.equal(attempts[1].paired,ordering!=='unpaired-overlap');assert.equal(attempts[1].nodeStartedAfterThisWebFinished,false);
assert.equal(committed.routes.root.module.loader(),expected);assert.equal(committed.assets.routes.root.css[0],'/new.css');assert.equal(errors.length,0);assert.equal(warnings.length,0);assert.equal(evaluations,expected==='new'?2:1);
return{version,patched,ordering,controllerGeneratedPairedIdentity:attempts[1].paired,nodeWebReferencesPreviousCompilation:true,loaderValue:committed.routes.root.module.loader(),css:committed.assets.routes.root.css[0],evaluations};
}
(async()=>{const results=[];for(const version of Object.keys(dirs))for(const patched of [false,true])for(const ordering of ['paired-aggregate-hook','paired-with-invalid-hooks','unpaired-overlap'])results.push(await run(version,patched,ordering));console.log(JSON.stringify({method:'Actual controller+coordinator+identity helpers; fake compiler hooks, mocked bundle evaluation, no manual attempt identities, no bundler/server/browser',results},null,2));})().catch(e=>{console.error(e);process.exitCode=1});Cause and candidate
When Node starts before Web finishes, identity.nodeWeb points at the previous completed Web compilation. identity.attempt nevertheless proves that the new Node/Web results are a pair. reusePreviousNodeBuild currently checks the former without honoring the latter.
hasOnlyCssAssetOwnershipChanges compares manifest fields and URLs; it does not compare loader bodies. A loader edit can leave its export flag and development module URL unchanged, so that predicate cannot establish that the old server code is safe to reuse.
Candidate change in src/dev-generation.ts:
- (!nodeChanged || identity.nodeWeb !== webIdentity);
+ (!nodeChanged ||
+ (!identity.attempt && identity.nodeWeb !== webIdentity));This lets the existing evaluation/pairing path handle changed Node results from a proved pair, while retaining unchanged-Node and unpaired CSS-only reuse.
Validation boundary
The executed harness covered 12 combinations across 0.7.1/0.8.0 (two versions × stock/candidate × three orderings), followed by six installed-0.8.0 cases. Stock committed new CSS with the old loader in both paired orderings; the candidate selected the new loader. Unpaired overlap retained the prior Node build in both arms. No warnings or evaluation errors occurred.
The upstream test suite and real compiler/server/browser integration were not run for this candidate. Acceptance should add the paired CSS-plus-loader edit to tests/dev-runtime-controller.test.ts, preserve the unpaired control, and confirm fresh HTTP requests return the new loader after an actual parallel rebuild.
Activity
[-][Bug]: paired CSS ownership and loader edits can commit a stale server build[/-][+][Bug]: paired CSS and loader edits can commit the previous server loader[/+]ScriptedAlchemy commentedon Sep 25, 2026
Fixed by #149.