Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion examples/default-template/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export default defineConfig({
},
// Run tests in files in parallel
fullyParallel: false,
// This suite includes dev-route-watch, which mutates routes.ts and restarts
// the shared dev server. Keep this example serial so other tests do not race
// the intentional restart.
workers: 1,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry on CI only
Expand Down Expand Up @@ -47,4 +51,4 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
});
151 changes: 151 additions & 0 deletions examples/default-template/tests/e2e/dev-route-watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect, test, type Page } from '@playwright/test';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const appDirectory = join(__dirname, '../../app');
const restartMarkerPath = join(
__dirname,
'../../build/client/.react-router/route-watch'
);
const routesConfigPath = join(appDirectory, 'routes.ts');
const addedRoutePath = join(appDirectory, 'routes/dev-added-route.tsx');
const addedRouteUrl = '/dev-added-route';
const addedRouteText = 'Route added while dev server is running';
const editedAddedRouteText = 'Route edited without dev server restart';
const addedRouteConfigEntry = ` route('dev-added-route', 'routes/dev-added-route.tsx'),`;

const removeAddedRouteConfig = (): boolean => {
const routesConfig = readFileSync(routesConfigPath, 'utf8');
if (routesConfig.includes(addedRouteConfigEntry)) {
writeFileSync(
routesConfigPath,
routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '')
);
return true;
}
return false;
};

const removeAddedRouteFile = (): boolean => {
if (existsSync(addedRoutePath)) {
rmSync(addedRoutePath, { force: true });
return true;
}
return false;
};

const readRestartMarker = (): string | null =>
existsSync(restartMarkerPath)
? readFileSync(restartMarkerPath, 'utf8')
: null;

const expectRestartMarkerStable = async (
expectedMarker: string | null,
quietMs = 750
) => {
const startedAt = Date.now();
await expect
.poll(
() => {
const marker = readRestartMarker();
if (marker !== expectedMarker) {
return `changed:${marker ?? 'missing'}`;
}
return Date.now() - startedAt >= quietMs ? 'stable' : 'waiting';
},
{ intervals: [100], timeout: quietMs + 1000 }
)
.toBe('stable');
};

const waitForRouteText = async (
page: Page,
url: string,
text: string
) => {
await expect
.poll(
async () => {
try {
const response = await page.request.get(url, {
timeout: 2000,
});
if (!response.ok()) {
return `status:${response.status()}`;
}
const body = await response.text();
return body.includes(text) ? 'ready' : 'missing-text';
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
{ timeout: 60000 }
)
.toBe('ready');
};

test.describe('dev route watch', () => {
test.setTimeout(90000);

test.beforeEach(async ({ page }) => {
if (removeAddedRouteConfig()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
if (removeAddedRouteFile()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
});

test.afterEach(async ({ page }) => {
if (removeAddedRouteConfig()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
if (removeAddedRouteFile()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
});

test('serves a route added after the dev server starts without restarting on later edits', async ({
page,
}) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Welcome to React Router');

writeFileSync(
addedRoutePath,
`export default function DevAddedRoute() {
return <h1>${addedRouteText}</h1>;
}
`
);

const routesConfig = readFileSync(routesConfigPath, 'utf8');
writeFileSync(
routesConfigPath,
routesConfig.replace(
' // Docs section with nested routes',
`${addedRouteConfigEntry}\n\n // Docs section with nested routes`
)
);

await waitForRouteText(page, addedRouteUrl, addedRouteText);

await page.goto(addedRouteUrl);
await expect(page.locator('h1')).toHaveText(addedRouteText);

await expect.poll(readRestartMarker, { timeout: 10000 }).not.toBe(null);
const restartMarkerBefore = readRestartMarker();
writeFileSync(
addedRoutePath,
`export default function DevAddedRoute() {
return <h1>${editedAddedRouteText}</h1>;
}
`
);

await waitForRouteText(page, addedRouteUrl, editedAddedRouteText);
await expectRestartMarkerStable(restartMarkerBefore);
});
});
103 changes: 85 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ import {
createRouteChunkArtifact,
createRouteClientEntryArtifact,
} from './route-artifacts.js';
import {
createRouteTopologyWatcher,
createRouteManifestSnapshot,
emitRouteRestartMarkerAsset,
ensureDevRestartMarker,
getRouteRestartMarkerPath,
mergeWatchFiles,
type WatchFileConfig,
} from './route-watch.js';
import { validateRouteConfig } from './route-config.js';
import {
getBuildManifest,
Expand Down Expand Up @@ -198,7 +207,9 @@ export const pluginReactRouter = (
});
});

const jiti = createJiti(process.cwd());
const jiti = createJiti(process.cwd(), {
moduleCache: false,
});

// Read the react-router.config file first (supports .ts, .js, .mjs, etc.)
const configPath = findEntryFile(resolve('react-router.config'));
Expand Down Expand Up @@ -326,21 +337,24 @@ export const pluginReactRouter = (
);
}

const routeConfigExport = await jiti.import<RouteConfigEntry[]>(
routesPath,
{
default: true,
const loadRouteConfig = async (): Promise<RouteConfigEntry[]> => {
const routeConfigExport = await jiti.import<RouteConfigEntry[]>(
routesPath,
{
default: true,
}
);
const routeConfigValue = await routeConfigExport;
const validation = validateRouteConfig({
routeConfigFile: relative(process.cwd(), routesPath),
routeConfig: routeConfigValue,
});
if (!validation.valid) {
throw new Error(validation.message);
}
);
const routeConfigValue = await routeConfigExport;
const validation = validateRouteConfig({
routeConfigFile: relative(process.cwd(), routesPath),
routeConfig: routeConfigValue,
});
if (!validation.valid) {
throw new Error(validation.message);
}
const routeConfig = validation.routeConfig;
return validation.routeConfig;
};
const routeConfig = await loadRouteConfig();

const entryClientPath = findEntryFile(
resolve(appDirectory, 'entry.client')
Expand Down Expand Up @@ -372,6 +386,14 @@ export const pluginReactRouter = (
// React Router's server build expects route files relative to `appDirectory`
// so it can resolve them correctly during compilation.
const rootRouteFile = relative(appDirectory, rootRoutePath);
const getWatchedRouteTopology = async (): Promise<Set<string>> => {
const latestRouteConfig = await loadRouteConfig();
const latestRoutes = {
root: { path: '', id: 'root', file: rootRouteFile },
...configRoutesToRouteManifest(appDirectory, latestRouteConfig),
};
return createRouteManifestSnapshot(latestRoutes);
};

const routes = {
root: { path: '', id: 'root', file: rootRouteFile },
Expand Down Expand Up @@ -411,6 +433,40 @@ export const pluginReactRouter = (
isBuild,
cache: routeChunkCache,
};
const outputClientPath = resolve(buildDirectory, 'client');
const assetsBuildDirectory = relative(process.cwd(), outputClientPath);
const watchDirectory = resolve(appDirectory);
const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath);
const routeWatchFiles: WatchFileConfig[] = [
{
paths: routesPath,
type: 'reload-server',
},
{
paths: routeRestartMarkerPath,
type: 'reload-server',
},
];
let closeRouteTopologyWatcher: (() => void) | undefined;

api.onBeforeStartDevServer(async () => {
await ensureDevRestartMarker(routeRestartMarkerPath);
closeRouteTopologyWatcher = await createRouteTopologyWatcher({
watchDirectory,
getRouteTopology: getWatchedRouteTopology,
restartMarkerPath: routeRestartMarkerPath,
onError: error => {
api.logger.warn(
`[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}`
);
},
});
});

api.onCloseDevServer(() => {
closeRouteTopologyWatcher?.();
closeRouteTopologyWatcher = undefined;
});

type ReactRouterManifest = Awaited<
ReturnType<typeof getReactRouterManifestForDev>
Expand Down Expand Up @@ -466,9 +522,6 @@ export const pluginReactRouter = (
});
const routesByServerBundleId = getRoutesByServerBundleId(buildManifest);

const outputClientPath = resolve(buildDirectory, 'client');
const assetsBuildDirectory = relative(process.cwd(), outputClientPath);

let clientStats: ReactRouterManifestStats | undefined;
api.onAfterEnvironmentCompile(({ stats, environment }) => {
if (environment.name === 'web') {
Expand Down Expand Up @@ -1149,6 +1202,7 @@ export const pluginReactRouter = (
dev: {
writeToDisk: true,
...lazyCompilation,
watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles),
// Only add SSR middleware if SSR is enabled and not using a custom server
// In SPA mode (ssr: false), we just serve static files from the client build
setupMiddlewares:
Expand Down Expand Up @@ -1333,6 +1387,19 @@ export const pluginReactRouter = (
}
);

if (isBuild) {
api.processAssets(
{ stage: 'additional', targets: ['web'] },
({ sources, compilation }) => {
emitRouteRestartMarkerAsset({
restartMarkerPath: routeRestartMarkerPath,
sources,
compilation,
});
}
);
}

api.processAssets(
{ stage: 'additional', targets: ['node'] },
({ sources, compilation }) => {
Expand Down
Loading