@rangojs/router 0.0.0-experimental.43 → 0.0.0-experimental.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/rango.js CHANGED
@@ -1063,8 +1063,9 @@ function createVersionPlugin() {
1063
1063
  let isDev = false;
1064
1064
  let server = null;
1065
1065
  const clientModuleSignatures = /* @__PURE__ */ new Map();
1066
+ let versionCounter = 0;
1066
1067
  const bumpVersion = (reason) => {
1067
- currentVersion = Date.now().toString(16);
1068
+ currentVersion = Date.now().toString(16) + String(++versionCounter);
1068
1069
  console.log(`[rsc-router] ${reason}, version updated: ${currentVersion}`);
1069
1070
  const rscEnv = server?.environments?.rsc;
1070
1071
  const versionMod = rscEnv?.moduleGraph?.getModuleById(
@@ -1120,6 +1121,9 @@ function createVersionPlugin() {
1120
1121
  if (!isDev) return;
1121
1122
  const isRscModule = this.environment?.name === "rsc";
1122
1123
  if (!isRscModule) return;
1124
+ if (ctx.modules.length === 1 && ctx.modules[0].id === "\0" + VIRTUAL_IDS.version) {
1125
+ return;
1126
+ }
1123
1127
  if (isCodeModule(ctx.file)) {
1124
1128
  const filePath = normalizeModuleId(ctx.file);
1125
1129
  const previousSignature = clientModuleSignatures.get(filePath);
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.43",
1748
+ version: "0.0.0-experimental.45",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
@@ -2694,8 +2694,9 @@ function createVersionPlugin() {
2694
2694
  let isDev = false;
2695
2695
  let server = null;
2696
2696
  const clientModuleSignatures = /* @__PURE__ */ new Map();
2697
+ let versionCounter = 0;
2697
2698
  const bumpVersion = (reason) => {
2698
- currentVersion = Date.now().toString(16);
2699
+ currentVersion = Date.now().toString(16) + String(++versionCounter);
2699
2700
  console.log(`[rsc-router] ${reason}, version updated: ${currentVersion}`);
2700
2701
  const rscEnv = server?.environments?.rsc;
2701
2702
  const versionMod = rscEnv?.moduleGraph?.getModuleById(
@@ -2751,6 +2752,9 @@ function createVersionPlugin() {
2751
2752
  if (!isDev) return;
2752
2753
  const isRscModule = this.environment?.name === "rsc";
2753
2754
  if (!isRscModule) return;
2755
+ if (ctx.modules.length === 1 && ctx.modules[0].id === "\0" + VIRTUAL_IDS.version) {
2756
+ return;
2757
+ }
2754
2758
  if (isCodeModule(ctx.file)) {
2755
2759
  const filePath = normalizeModuleId(ctx.file);
2756
2760
  const previousSignature = clientModuleSignatures.get(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.43",
3
+ "version": "0.0.0-experimental.45",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
79
79
  state: "idle" | "loading";
80
80
  /** Whether any operation is streaming */
81
81
  isStreaming: boolean;
82
+ /** Whether a navigation is active (fetching or streaming, before commit) */
83
+ isNavigating: boolean;
82
84
  /** Current committed location */
83
85
  location: NavigationLocation;
84
86
  /** URL being navigated to (null if idle) */
@@ -389,6 +391,9 @@ export function createEventController(
389
391
  return {
390
392
  state,
391
393
  isStreaming,
394
+ // True when a navigation is active (fetching or streaming, before
395
+ // commit). Broader than pendingUrl which clears during streaming.
396
+ isNavigating: currentNavigation !== null,
392
397
  location,
393
398
  // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
399
  // Background revalidations (skipLoadingState) don't expose a pending URL.
@@ -263,71 +263,123 @@ export async function initBrowserApp(
263
263
  // Build initial tree with rootLayout
264
264
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
265
 
266
- // Setup HMR
266
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
267
+ // fire many rsc:update events in quick succession. Without debouncing,
268
+ // each event triggers a fetchPartial() which on slow routes can pile up
269
+ // and overwhelm the worker (cross-request promise issues, 500s).
267
270
  if (import.meta.hot) {
268
- import.meta.hot.on("rsc:update", async () => {
269
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
270
-
271
- const handle = eventController.startNavigation(window.location.href, {
272
- replace: true,
273
- });
274
- const streamingToken = handle.startStreaming();
275
-
276
- const interceptSourceUrl = store.getInterceptSourceUrl();
277
-
278
- try {
279
- const { payload, streamComplete } = await client.fetchPartial({
280
- targetUrl: window.location.href,
281
- segmentIds: [],
282
- previousUrl: store.getSegmentState().currentUrl,
283
- interceptSourceUrl: interceptSourceUrl || undefined,
284
- hmr: true,
285
- });
271
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
272
+ let hmrAbort: AbortController | null = null;
286
273
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
274
+ import.meta.hot.on("rsc:update", () => {
275
+ // Cancel any pending debounce timer
276
+ if (hmrTimer !== null) {
277
+ clearTimeout(hmrTimer);
278
+ }
290
279
 
291
- // Derive intercept state from the returned payload, not the
292
- // pre-fetch store snapshot. If the HMR edit removed intercept
293
- // behavior, the response won't contain intercept segments.
294
- const responseIsIntercept = segments.some(isInterceptSegment);
280
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
281
+ if (hmrAbort) {
282
+ hmrAbort.abort();
283
+ hmrAbort = null;
284
+ }
295
285
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- store.setInterceptSourceUrl(null);
299
- }
286
+ // Debounce: wait 200ms of quiet before fetching
287
+ hmrTimer = setTimeout(async () => {
288
+ hmrTimer = null;
289
+
290
+ // Don't interrupt an active user navigation — startNavigation()
291
+ // would abort it and refetch the old URL (window.location.href
292
+ // hasn't updated yet). The user's navigation will pick up the
293
+ // new server code when it completes. isNavigating covers the
294
+ // full lifecycle (fetching + streaming, before commit) without
295
+ // blocking on server actions.
296
+ if (eventController.getState().isNavigating) {
297
+ console.log("[RSCRouter] HMR: Skipping — navigation in progress");
298
+ return;
299
+ }
300
300
 
301
- store.setSegmentIds(matched);
302
- store.setCurrentUrl(window.location.href);
301
+ console.log("[RSCRouter] HMR: Server update, refetching RSC");
303
302
 
304
- const historyKey = generateHistoryKey(window.location.href, {
305
- intercept: responseIsIntercept,
306
- });
307
- store.setHistoryKey(historyKey);
308
- const currentHandleData = eventController.getHandleState().data;
309
- store.cacheSegmentsForHistory(
310
- historyKey,
311
- segments,
312
- currentHandleData,
313
- );
314
-
315
- const { main, intercept } = splitInterceptSegments(segments);
316
- store.emitUpdate({
317
- root: renderSegments(main, {
318
- interceptSegments: intercept.length > 0 ? intercept : undefined,
319
- }),
320
- metadata: payload.metadata,
303
+ const abort = new AbortController();
304
+ hmrAbort = abort;
305
+
306
+ const handle = eventController.startNavigation(window.location.href, {
307
+ replace: true,
308
+ });
309
+ const streamingToken = handle.startStreaming();
310
+
311
+ const interceptSourceUrl = store.getInterceptSourceUrl();
312
+
313
+ try {
314
+ const { payload, streamComplete } = await client.fetchPartial({
315
+ targetUrl: window.location.href,
316
+ segmentIds: [],
317
+ previousUrl: store.getSegmentState().currentUrl,
318
+ interceptSourceUrl: interceptSourceUrl || undefined,
319
+ hmr: true,
320
+ signal: abort.signal,
321
321
  });
322
- }
323
322
 
324
- await streamComplete;
325
- handle.complete(new URL(window.location.href));
326
- console.log("[RSCRouter] HMR: RSC stream complete");
327
- } finally {
328
- streamingToken.end();
329
- handle[Symbol.dispose]();
330
- }
323
+ if (abort.signal.aborted) return;
324
+
325
+ // If the server returned a non-RSC response (404, 500 without
326
+ // error boundary), the payload won't have valid metadata.
327
+ // Reload to recover rather than leaving the page stale.
328
+ if (!payload.metadata) {
329
+ throw new Error("HMR refetch returned invalid payload");
330
+ }
331
+
332
+ if (payload.metadata?.isPartial) {
333
+ const segments = payload.metadata.segments || [];
334
+ const matched = payload.metadata.matched || [];
335
+
336
+ // Derive intercept state from the returned payload, not the
337
+ // pre-fetch store snapshot. If the HMR edit removed intercept
338
+ // behavior, the response won't contain intercept segments.
339
+ const responseIsIntercept = segments.some(isInterceptSegment);
340
+
341
+ // Sync store intercept state with what the server returned
342
+ if (!responseIsIntercept && interceptSourceUrl) {
343
+ store.setInterceptSourceUrl(null);
344
+ }
345
+
346
+ store.setSegmentIds(matched);
347
+ store.setCurrentUrl(window.location.href);
348
+
349
+ const historyKey = generateHistoryKey(window.location.href, {
350
+ intercept: responseIsIntercept,
351
+ });
352
+ store.setHistoryKey(historyKey);
353
+ const currentHandleData = eventController.getHandleState().data;
354
+ store.cacheSegmentsForHistory(
355
+ historyKey,
356
+ segments,
357
+ currentHandleData,
358
+ );
359
+
360
+ const { main, intercept } = splitInterceptSegments(segments);
361
+ store.emitUpdate({
362
+ root: renderSegments(main, {
363
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
364
+ }),
365
+ metadata: payload.metadata,
366
+ });
367
+ }
368
+
369
+ await streamComplete;
370
+ handle.complete(new URL(window.location.href));
371
+ console.log("[RSCRouter] HMR: RSC stream complete");
372
+ } catch (err) {
373
+ if (abort.signal.aborted) return;
374
+ console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
375
+ window.location.reload();
376
+ return;
377
+ } finally {
378
+ if (hmrAbort === abort) hmrAbort = null;
379
+ streamingToken.end();
380
+ handle[Symbol.dispose]();
381
+ }
382
+ }, 200);
331
383
  });
332
384
  }
333
385
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
15
15
  import { getRequestContext } from "../server/request-context.js";
16
+ import { mayNeedSSR } from "../rsc/ssr-setup.js";
16
17
  import { sortedSearchString } from "./cache-key-utils.js";
17
18
  import { runBackground } from "./background-task.js";
18
19
 
@@ -241,9 +242,12 @@ export function createDocumentCacheMiddleware<TEnv = any>(
241
242
  return next();
242
243
  }
243
244
 
244
- // Determine request type for cache key differentiation
245
+ // Determine request type for cache key differentiation.
246
+ // Full-document RSC fetches (Accept: text/x-component or __rsc) must not
247
+ // share the HTML cache slot for the same pathname.
245
248
  const isPartial = url.searchParams.has("_rsc_partial");
246
- const typeLabel = isPartial ? "RSC" : "HTML";
249
+ const isRscRequest = !mayNeedSSR(ctx.request, url);
250
+ const typeLabel = isRscRequest ? "RSC" : "HTML";
247
251
 
248
252
  // Track whether next() has been called so the catch block knows
249
253
  // whether it is safe to fall through to the handler.
@@ -257,7 +261,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
257
261
  const clientSegments = url.searchParams.get("_rsc_segments") || "";
258
262
  const segmentHash =
259
263
  isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
260
- const typeSuffix = isPartial ? ":rsc" : ":html";
264
+ const typeSuffix = isRscRequest ? ":rsc" : ":html";
261
265
 
262
266
  let searchSuffix = "";
263
267
  if (!keyGenerator) {
package/src/ssr/index.tsx CHANGED
@@ -168,6 +168,7 @@ function createSsrEventController(opts: {
168
168
  const state: DerivedNavigationState = {
169
169
  state: "idle",
170
170
  isStreaming: false,
171
+ isNavigating: false,
171
172
  location,
172
173
  pendingUrl: null,
173
174
  inflightActions: [],
@@ -135,8 +135,11 @@ export function createVersionPlugin(): Plugin {
135
135
  let server: any = null;
136
136
  const clientModuleSignatures = new Map<string, ClientModuleSignature>();
137
137
 
138
+ let versionCounter = 0;
138
139
  const bumpVersion = (reason: string) => {
139
- currentVersion = Date.now().toString(16);
140
+ // Use timestamp + counter to guarantee uniqueness even when multiple
141
+ // bumps happen within the same millisecond (e.g. cascading HMR events).
142
+ currentVersion = Date.now().toString(16) + String(++versionCounter);
140
143
  console.log(`[rsc-router] ${reason}, version updated: ${currentVersion}`);
141
144
 
142
145
  const rscEnv = server?.environments?.rsc;
@@ -211,6 +214,15 @@ export function createVersionPlugin(): Plugin {
211
214
 
212
215
  if (!isRscModule) return;
213
216
 
217
+ // Skip re-bumping when the version virtual module itself is invalidated
218
+ // (our own bumpVersion() invalidates it, which re-triggers hotUpdate).
219
+ if (
220
+ ctx.modules.length === 1 &&
221
+ ctx.modules[0].id === "\0" + VIRTUAL_IDS.version
222
+ ) {
223
+ return;
224
+ }
225
+
214
226
  if (isCodeModule(ctx.file)) {
215
227
  const filePath = normalizeModuleId(ctx.file);
216
228
  const previousSignature = clientModuleSignatures.get(filePath);