Skip to content

[Bug]: paired CSS and loader edits can commit the previous server loader #147

Description

@matthewdavis-oai

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 ./package

Save 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+'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/src/dev-runtime-artifacts.ts');artifacts=artifacts.slice(0,artifacts.indexOf('const startServerBuildEvaluationEffect'))+artifacts.slice(artifacts.indexOf('const assertBuildMatchesManifest'));
 let generation=strip(dir+'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/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+'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/src/dev-runtime-compilation.ts'),version==='0.8.0'?strip(dir+'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/src/dev-hdr-intent.ts'):'',strip(dir+'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/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

  1. changed the title [-][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[/+] on Sep 22, 2026
  2. ScriptedAlchemy commented on Sep 25, 2026

    @ScriptedAlchemy
    Collaborator

    Fixed by #149.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions