@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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.
Files changed (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -43,6 +43,10 @@ import {
43
43
  } from "../server/request-context.js";
44
44
  import { seedVariables, type VarsInit } from "./internal/seed-vars.js";
45
45
  import { normalizeFlight } from "./flight-normalize.js";
46
+ import { resolveThemeConfig } from "../theme/constants.js";
47
+ import type { ThemeConfig } from "../theme/types.js";
48
+ import type { SegmentCacheStore } from "../cache/types.js";
49
+ import type { CacheProfile } from "../cache/profile-registry.js";
46
50
  import type { RscPayload } from "../rsc/types.js";
47
51
  import type { ResolvedSegment } from "../types.js";
48
52
 
@@ -86,6 +90,23 @@ export interface RenderToFlightStringOptions {
86
90
  * Object form (`{ user }`) or `[key, value]` tuples (`[[userVar, u]]`).
87
91
  */
88
92
  vars?: VarsInit;
93
+ /**
94
+ * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
95
+ * or `{ themes: [...] }`). Without it `getRequestContext().theme` is `undefined`
96
+ * and `ctx.setTheme` is inert — pass one to render a server component that
97
+ * reads `ctx.theme`. Threaded into the SAME createRequestContext renderHandler
98
+ * uses, so the two Flight primitives expose theme identically.
99
+ */
100
+ theme?: ThemeConfig | true;
101
+ /**
102
+ * Cache store backing a `"use cache"` function the rendered server tree
103
+ * invokes. Without it, `registerCachedFunction` takes the uncached bypass and
104
+ * the cached path is NOT exercised. Pair with `cacheProfiles` so a
105
+ * `"use cache: profileName"` directive resolves its profile.
106
+ */
107
+ cacheStore?: SegmentCacheStore;
108
+ /** Cache profiles in the `createRouter({ cacheProfiles })` shape. */
109
+ cacheProfiles?: Record<string, CacheProfile>;
89
110
  }
90
111
 
91
112
  const DEFAULT_URL = "http://localhost/";
@@ -191,6 +212,10 @@ export async function serializeToFlightString(
191
212
  request,
192
213
  url,
193
214
  variables: seedVariables({}, opts.vars),
215
+ themeConfig:
216
+ opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
217
+ cacheStore: opts.cacheStore,
218
+ cacheProfiles: opts.cacheProfiles,
194
219
  });
195
220
 
196
221
  return runWithRequestContext(ctx, () => {
@@ -12,6 +12,7 @@ import {
12
12
  runWithRequestContext,
13
13
  type RequestContext,
14
14
  } from "../../server/request-context.js";
15
+ import { drainOnResponseCallbacks } from "../../rsc/helpers.js";
15
16
  import { resolveLocationStateEntries } from "../../browser/react/location-state-shared.js";
16
17
  import { createReverseFunction } from "../../router/handler-context.js";
17
18
  import { normalizeBasename } from "../../router/basename.js";
@@ -259,6 +260,7 @@ export function buildRunResponse<TEnv>(
259
260
  thrown: unknown,
260
261
  ): Response {
261
262
  const stub = ctx.res;
263
+ let response: Response;
262
264
  if (thrown instanceof Response) {
263
265
  const headers = new Headers(thrown.headers);
264
266
  for (const cookie of stub.headers.getSetCookie()) {
@@ -268,9 +270,30 @@ export function buildRunResponse<TEnv>(
268
270
  if (name.toLowerCase() === "set-cookie") return;
269
271
  if (!headers.has(name)) headers.set(name, value);
270
272
  });
271
- return new Response(null, { status: thrown.status, headers });
273
+ response = new Response(null, { status: thrown.status, headers });
274
+ } else {
275
+ response = new Response(null, {
276
+ status: stub.status,
277
+ headers: stub.headers,
278
+ });
272
279
  }
273
- return new Response(null, { status: stub.status, headers: stub.headers });
280
+ // Mirror production response finalization: every response-finalization path
281
+ // drains ctx.onResponse() callbacks (createResponseWithMergedHeaders /
282
+ // finalizeResponse). buildRunResponse runs AFTER runWithRequestContext has
283
+ // exited, so _getRequestContext() (and finalizeResponse) would no-op — drain
284
+ // ctx._onResponseCallbacks explicitly. Reuses the SAME production drain
285
+ // (swap-before-iterate + external-redirect brand preservation) so a callback's
286
+ // header mutations / returned replacement Response are reflected on the result
287
+ // the harness surfaces. rsc/helpers is plugin-rsc-free on its eager graph
288
+ // (dispatch.ts in this same testing barrel already statically imports from it).
289
+ // drainOnResponseCallbacks is typed against the default-env RequestContext (it
290
+ // touches only the env-agnostic _onResponseCallbacks); the harness ctx is
291
+ // RequestContext<TEnv> — assignable in the router's own tsc but not when a
292
+ // consumer pins a concrete Env, so cast to the param type.
293
+ return drainOnResponseCallbacks(
294
+ ctx as Parameters<typeof drainOnResponseCallbacks>[0],
295
+ response,
296
+ );
274
297
  }
275
298
 
276
299
  export function buildRunSnapshot<TEnv>(
@@ -276,7 +276,9 @@ export async function renderHandler<TEnv = any>(
276
276
  handlePushes.set(handle, pushed);
277
277
  });
278
278
  }
279
- if (loaderSeeds.has(item)) return loaderSeeds.get(item);
279
+ // Production ctx.use(Loader) ALWAYS returns a Promise (the cached loader
280
+ // promise); wrap the seed so a handler composing on the result matches.
281
+ if (loaderSeeds.has(item)) return Promise.resolve(loaderSeeds.get(item));
280
282
  throw new RenderHandlerSetupError(
281
283
  `renderHandler: ctx.use(loader) was not seeded. Pass ` +
282
284
  `{ loaders: [[YourLoader, data]] } for each loader the handler reads.`,
@@ -250,6 +250,20 @@ export interface RenderRouteOptions {
250
250
  * component.
251
251
  */
252
252
  theme?: ThemeConfig | true;
253
+ /**
254
+ * CSP nonce to seed via NonceContext, so a component calling `useNonce()`
255
+ * (e.g. an analytics/GTM head-script component) sees this value — mirroring
256
+ * what the SSR renderer provides per request. Defaults to undefined (the
257
+ * browser default), matching production client behavior.
258
+ *
259
+ * @example
260
+ * const { getByTestId } = await renderRoute(
261
+ * [{ path: "/", Component: NonceProbe }],
262
+ * { nonce: "test-nonce" },
263
+ * );
264
+ * expect(getByTestId("nonce").textContent).toBe("test-nonce");
265
+ */
266
+ nonce?: string;
253
267
  }
254
268
 
255
269
  /**
@@ -536,6 +550,7 @@ export async function renderRoute(
536
550
  themeConfig={
537
551
  options.theme === undefined ? null : resolveThemeConfig(options.theme)
538
552
  }
553
+ nonce={options.nonce}
539
554
  />,
540
555
  );
541
556
  });
@@ -106,7 +106,9 @@ export interface RunLoaderOptions<TEnv = any> {
106
106
  basename?: string;
107
107
  /**
108
108
  * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
109
- * or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert.
109
+ * or `{ themes: [...] }`). Seeds the request's theme config so nested handler
110
+ * or cache contexts created from this loader observe it. Loaders themselves do
111
+ * not expose `ctx.theme`/`ctx.setTheme` (those are handler/middleware-only).
110
112
  */
111
113
  theme?: ThemeConfig | true;
112
114
  /** Environment bindings surfaced as `ctx.env`. */
@@ -284,8 +286,13 @@ function runWithLoaderContext<R>(
284
286
  }
285
287
  if (handleSeeds.has(dep)) return handleSeeds.get(dep);
286
288
  if (isHandle(dep)) return collectHandle(dep, []);
287
- if (loaderSeeds.has(dep)) return loaderSeeds.get(dep);
288
- if (opts.use) return opts.use(dep as LoaderDefinition<any, any>);
289
+ // Production ctx.use(Loader) ALWAYS returns a Promise (the cached loader
290
+ // promise). The seeded path must match, so a consumer composing on the
291
+ // result (ctx.use(Dep).then(...), Promise.race, etc.) works the same as
292
+ // production and the real-fn delegate path below.
293
+ if (loaderSeeds.has(dep)) return Promise.resolve(loaderSeeds.get(dep));
294
+ if (opts.use)
295
+ return Promise.resolve(opts.use(dep as LoaderDefinition<any, any>));
289
296
  return reqCtx.use(dep as LoaderDefinition<any, any>);
290
297
  }) as LoaderContext<any, any>["use"],
291
298
  method: opts.method ?? "GET",
@@ -26,7 +26,7 @@ import type {
26
26
  ThemeContextValue,
27
27
  ThemeProviderProps,
28
28
  } from "./types.js";
29
- import { THEME_COOKIE } from "./constants.js";
29
+ import { THEME_COOKIE, isValidTheme, warnInvalidTheme } from "./constants.js";
30
30
 
31
31
  function getSystemTheme(): ResolvedTheme {
32
32
  if (typeof window !== "undefined" && window.matchMedia) {
@@ -152,6 +152,15 @@ export function ThemeProvider({
152
152
 
153
153
  const setTheme = useCallback(
154
154
  (newTheme: Theme) => {
155
+ // Shared guard (isValidTheme) used by the server ctx.setTheme too: reject
156
+ // any value not in the configured theme set, AND reject "system" when
157
+ // system detection is off (applyThemeToDocument would write a bogus
158
+ // class="system"). Keeps the cookie from holding a value the server would
159
+ // reinterpret as defaultTheme on the next SSR (desyncing markup).
160
+ if (!isValidTheme(newTheme, config)) {
161
+ warnInvalidTheme(newTheme, config);
162
+ return;
163
+ }
155
164
  setThemeState(newTheme);
156
165
  writeThemeToCookie(config.storageKey, newTheme);
157
166
  writeThemeToStorage(config.storageKey, newTheme);
@@ -191,11 +200,16 @@ export function ThemeProvider({
191
200
  const newTheme = e.newValue;
192
201
  if (!newTheme) return;
193
202
 
194
- // Validate and apply
195
- if (newTheme === "system" || config.themes.includes(newTheme)) {
196
- setThemeState(newTheme as Theme);
197
- applyThemeToDocument(newTheme as Theme, config);
198
- }
203
+ // A cross-tab storage event can carry any value (another tab, or stale
204
+ // localStorage). Reuse the shared validity rule: reject anything not a
205
+ // configured theme, AND reject "system" when system detection is off (it
206
+ // would apply a bogus class="system"). An invalid received value falls back
207
+ // to defaultTheme rather than applying as-is.
208
+ const applied: Theme = isValidTheme(newTheme, config)
209
+ ? (newTheme as Theme)
210
+ : config.defaultTheme;
211
+ setThemeState(applied);
212
+ applyThemeToDocument(applied, config);
199
213
  };
200
214
 
201
215
  window.addEventListener("storage", handleStorageChange);
@@ -8,17 +8,21 @@
8
8
  *
9
9
  * Must be placed in the <head> element of your document, before any stylesheets.
10
10
  *
11
+ * Note: when theme is enabled in the router config, `<MetaTags />` ALREADY
12
+ * renders this FOUC script. Use `<ThemeScript />` only if you do NOT render
13
+ * `<MetaTags />`. Rendering both is safe — the inline script guards the
14
+ * matchMedia listener registration against double-running — but it is redundant.
15
+ *
11
16
  * @example
12
17
  * ```tsx
13
- * // In your document component
18
+ * // In your document component. Use ThemeScript only when you do not render MetaTags.
14
19
  * import { ThemeScript } from "@rangojs/router/theme";
15
20
  *
16
21
  * export function Document({ children }) {
17
22
  * return (
18
23
  * <html lang="en" suppressHydrationWarning>
19
24
  * <head>
20
- * <ThemeScript />
21
- * <MetaTags />
25
+ * <ThemeScript config={config} />
22
26
  * </head>
23
27
  * <body>{children}</body>
24
28
  * </html>
@@ -2,7 +2,7 @@
2
2
  * Default values for theme configuration
3
3
  */
4
4
 
5
- import type { ResolvedThemeConfig, ThemeConfig } from "./types.js";
5
+ import type { ResolvedThemeConfig, Theme, ThemeConfig } from "./types.js";
6
6
 
7
7
  export const THEME_DEFAULTS = {
8
8
  defaultTheme: "system",
@@ -23,6 +23,45 @@ export const THEME_COOKIE: {
23
23
  sameSite: "lax",
24
24
  };
25
25
 
26
+ /**
27
+ * Single owner of the setTheme validity rule, shared by the client
28
+ * (ThemeProvider) and server (ctx.setTheme) guards so they cannot drift.
29
+ *
30
+ * A theme is valid when it is one of the configured concrete themes, OR
31
+ * "system" but only while system detection is enabled. Rejecting "system" when
32
+ * `enableSystem` is false is load-bearing: applyThemeToDocument would otherwise
33
+ * leave "system" unresolved and write a bogus class="system" / colorScheme
34
+ * ="system" on <html> (the same bogus value resolveThemeConfig coerces away for
35
+ * the default).
36
+ */
37
+ export function isValidTheme(
38
+ theme: string,
39
+ config: Pick<ResolvedThemeConfig, "themes" | "enableSystem">,
40
+ ): boolean {
41
+ if (theme === "system") return config.enableSystem;
42
+ return config.themes.includes(theme);
43
+ }
44
+
45
+ /**
46
+ * Emit the shared "[Theme] Invalid theme value" warning. One owner of the
47
+ * message string so the client and server guards stay byte-identical.
48
+ *
49
+ * The valid-values list mirrors isValidTheme: "system" is only listed when
50
+ * enableSystem is true, otherwise the message would advertise a value the guard
51
+ * itself rejects.
52
+ */
53
+ export function warnInvalidTheme(
54
+ theme: string,
55
+ config: Pick<ResolvedThemeConfig, "themes" | "enableSystem">,
56
+ ): void {
57
+ const validValues = config.enableSystem
58
+ ? ["system", ...config.themes]
59
+ : config.themes;
60
+ console.warn(
61
+ `[Theme] Invalid theme value: "${theme}". Valid values: ${validValues.join(", ")}`,
62
+ );
63
+ }
64
+
26
65
  export function resolveThemeConfig(
27
66
  config: ThemeConfig | true,
28
67
  ): ResolvedThemeConfig {
@@ -37,12 +76,24 @@ export function resolveThemeConfig(
37
76
  value[theme] = config.value?.[theme] ?? theme;
38
77
  }
39
78
 
79
+ const enableSystem = config.enableSystem ?? THEME_DEFAULTS.enableSystem;
80
+
81
+ // When system detection is disabled, "system" is not a valid resolved theme.
82
+ // Coerce both the unset default and an explicit defaultTheme:"system" to the
83
+ // first concrete theme, so the FOUC script / ThemeProvider never apply a bogus
84
+ // class="system" / colorScheme="system" on <html>.
85
+ const requestedDefault = config.defaultTheme ?? THEME_DEFAULTS.defaultTheme;
86
+ const defaultTheme =
87
+ !enableSystem && requestedDefault === "system"
88
+ ? (themes[0] as Theme)
89
+ : requestedDefault;
90
+
40
91
  return {
41
- defaultTheme: config.defaultTheme ?? THEME_DEFAULTS.defaultTheme,
92
+ defaultTheme,
42
93
  themes,
43
94
  attribute: config.attribute ?? THEME_DEFAULTS.attribute,
44
95
  storageKey: config.storageKey ?? THEME_DEFAULTS.storageKey,
45
- enableSystem: config.enableSystem ?? THEME_DEFAULTS.enableSystem,
96
+ enableSystem,
46
97
  enableColorScheme:
47
98
  config.enableColorScheme ?? THEME_DEFAULTS.enableColorScheme,
48
99
  value,
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { ResolvedThemeConfig } from "./types.js";
14
+ import { escapeJsonForScript } from "../escape-script.js";
14
15
 
15
16
  /**
16
17
  * Generate the inline script for theme initialization
@@ -24,13 +25,13 @@ import type { ResolvedThemeConfig } from "./types.js";
24
25
  export function generateThemeScript(config: ResolvedThemeConfig): string {
25
26
  const script = `
26
27
  (function() {
27
- var storageKey = ${JSON.stringify(config.storageKey)};
28
- var defaultTheme = ${JSON.stringify(config.defaultTheme)};
29
- var attribute = ${JSON.stringify(config.attribute)};
28
+ var storageKey = ${escapeJsonForScript(JSON.stringify(config.storageKey))};
29
+ var defaultTheme = ${escapeJsonForScript(JSON.stringify(config.defaultTheme))};
30
+ var attribute = ${escapeJsonForScript(JSON.stringify(config.attribute))};
30
31
  var enableSystem = ${config.enableSystem};
31
32
  var enableColorScheme = ${config.enableColorScheme};
32
- var valueMap = ${JSON.stringify(config.value)};
33
- var themes = ${JSON.stringify(config.themes)};
33
+ var valueMap = ${escapeJsonForScript(JSON.stringify(config.value))};
34
+ var themes = ${escapeJsonForScript(JSON.stringify(config.themes))};
34
35
 
35
36
  function getStoredTheme() {
36
37
  var cookies = document.cookie.split(';');
@@ -83,13 +84,26 @@ export function generateThemeScript(config: ResolvedThemeConfig): string {
83
84
  }
84
85
 
85
86
  var stored = getStoredTheme();
86
- var theme = stored && (stored === 'system' || themes.indexOf(stored) !== -1)
87
+ // A stored value is valid when it is a configured theme, OR "system" but only
88
+ // while system detection is enabled. A stored "system" with enableSystem=false
89
+ // (an old cookie/localStorage, or a value pushed cross-tab) must fall back to
90
+ // defaultTheme — otherwise resolveTheme returns "system" unresolved and
91
+ // applyTheme writes a bogus class="system" / colorScheme="system" on <html>.
92
+ // Same rule as isValidTheme (constants.ts), inlined since this is a string.
93
+ var systemAllowed = stored === 'system' && enableSystem;
94
+ var theme = stored && (systemAllowed || themes.indexOf(stored) !== -1)
87
95
  ? stored
88
96
  : defaultTheme;
89
97
 
90
98
  applyTheme(theme);
91
99
 
92
- if (enableSystem && typeof window !== 'undefined' && window.matchMedia) {
100
+ // Idempotency guard: MetaTags auto-injects this script when theme is enabled,
101
+ // and ThemeScript is also a public component for the same job. If a consumer
102
+ // renders both, the IIFE runs twice; without this guard the second run would
103
+ // register a SECOND, never-removed matchMedia('change') listener (a leak).
104
+ // The flag is keyed by storageKey so independent theme configs don't collide.
105
+ var flagKey = '__rangoThemeListener_' + storageKey;
106
+ if (enableSystem && typeof window !== 'undefined' && window.matchMedia && !window[flagKey]) {
93
107
  try {
94
108
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
95
109
  var current = getStoredTheme() || defaultTheme;
@@ -97,6 +111,7 @@ export function generateThemeScript(config: ResolvedThemeConfig): string {
97
111
  applyTheme('system');
98
112
  }
99
113
  });
114
+ window[flagKey] = true;
100
115
  } catch (e) {
101
116
  // Older browsers may not support addEventListener on MediaQueryList
102
117
  }
@@ -25,9 +25,14 @@ export interface ExecutionContext {
25
25
  * of inventing its own fallback policy.
26
26
  */
27
27
  export function fireAndForgetWaitUntil(fn: () => Promise<void>): void {
28
- fn().catch((err) =>
29
- console.error("[waitUntil] Background task failed:", err),
30
- );
28
+ // Defer fn() invocation to a microtask so a SYNCHRONOUS throw in a non-async
29
+ // callback (e.g. `() => { somethingThatThrows(); return p; }`) becomes a
30
+ // rejected promise we catch here, not an exception that escapes into the
31
+ // request flow. waitUntil is fire-and-forget: a background-task failure must
32
+ // never break the response.
33
+ Promise.resolve()
34
+ .then(fn)
35
+ .catch((err) => console.error("[waitUntil] Background task failed:", err));
31
36
  }
32
37
 
33
38
  /**
@@ -8,14 +8,21 @@ const debug = createRangoDebugger(NS.transform);
8
8
  * The react-server-dom vendor files are shipped as CJS which doesn't work in browsers.
9
9
  */
10
10
  export function createCjsToEsmPlugin(): Plugin {
11
+ // Picked from Vite's resolved mode, not process.env.NODE_ENV, so the dev vs
12
+ // production vendor variant tracks the build mode the user actually ran.
13
+ let isProduction = false;
14
+
11
15
  return {
12
16
  name: "@rangojs/router:cjs-to-esm",
13
17
  enforce: "pre",
18
+ configResolved(config) {
19
+ isProduction = config.isProduction;
20
+ },
14
21
  transform(code, id) {
15
22
  const cleanId = id.split("?")[0].replaceAll("\\", "/");
16
23
 
17
24
  if (cleanId.includes("vendor/react-server-dom/client.browser.js")) {
18
- const isProd = process.env.NODE_ENV === "production";
25
+ const isProd = isProduction;
19
26
  const cjsFile = isProd
20
27
  ? "./cjs/react-server-dom-webpack-client.browser.production.js"
21
28
  : "./cjs/react-server-dom-webpack-client.browser.development.js";
@@ -210,7 +210,16 @@ export function countArgs(
210
210
  while (i < endPos) {
211
211
  const skipped = skipStringOrComment(code, i);
212
212
  if (skipped > i) {
213
- hasContent = true;
213
+ // A comment is transparent: `createHandle(/* meta */)` has ZERO real
214
+ // arguments, so a comment-only region must NOT set hasContent (else the
215
+ // fallback path would count it as 1 arg and inject `, "id"` over an elided
216
+ // first slot — a syntax error). A string/template literal IS content.
217
+ const ch = code[i];
218
+ const isComment =
219
+ ch === "/" &&
220
+ i + 1 < code.length &&
221
+ (code[i + 1] === "/" || code[i + 1] === "*");
222
+ if (!isComment) hasContent = true;
214
223
  i = skipped;
215
224
  continue;
216
225
  }
@@ -3,35 +3,24 @@ import { makeStubId } from "../expose-id-utils.js";
3
3
  import type { HandlerTransformConfig, CreateExportBinding } from "./types.js";
4
4
  import { isExportOnlyFile } from "./export-analysis.js";
5
5
 
6
- function analyzeCreateHandleArgs(
7
- code: string,
8
- startPos: number,
9
- endPos: number,
10
- ): { hasArgs: boolean } {
11
- const content = code.slice(startPos, endPos).trim();
12
- return { hasArgs: content.length > 0 };
13
- }
14
-
15
6
  export function transformHandles(
16
7
  bindings: CreateExportBinding[],
17
8
  s: MagicString,
18
- code: string,
19
9
  filePath: string,
20
10
  isBuild: boolean,
21
11
  ): boolean {
22
12
  let hasChanges = false;
23
13
  for (const binding of bindings) {
24
14
  const exportName = binding.exportNames[0];
25
- const args = analyzeCreateHandleArgs(
26
- code,
27
- binding.callOpenParenPos + 1,
28
- binding.callCloseParenPos,
29
- );
30
15
 
31
16
  const handleId = makeStubId(filePath, exportName, isBuild);
32
17
 
18
+ // Branch on the AST-derived argCount, not a string-trim of the raw arg
19
+ // slice. A comment-only arg list (`createHandle(/* meta */)`) trims to
20
+ // non-empty but is zero real arguments; injecting `, "id"` would emit
21
+ // `createHandle(/* meta */, "id")` — an elided first arg / syntax error.
33
22
  let paramInjection: string;
34
- if (!args.hasArgs) {
23
+ if (binding.argCount === 0) {
35
24
  paramInjection = `undefined, "${handleId}"`;
36
25
  } else {
37
26
  paramInjection = `, "${handleId}"`;
@@ -20,11 +20,18 @@ export function generateClientLoaderStubs(
20
20
  const lines: string[] = [];
21
21
 
22
22
  for (const binding of bindings) {
23
- for (const name of binding.exportNames) {
24
- const loaderId = makeStubId(filePath, name, isBuild);
25
- lines.push(
26
- `export const ${name} = { __brand: "loader", $$id: "${loaderId}" };`,
27
- );
23
+ // Aliases share the primary export's id (matches the server side, which
24
+ // registers only exportNames[0] in the loader registry, and the mixed-type
25
+ // whole-file path). Emitting a distinct makeStubId per alias would make a
26
+ // client component importing the alias fetch an id absent from the server
27
+ // registry, so the fetchable-loader request would 404.
28
+ const primaryName = binding.exportNames[0];
29
+ const loaderId = makeStubId(filePath, primaryName, isBuild);
30
+ lines.push(
31
+ `export const ${primaryName} = { __brand: "loader", $$id: "${loaderId}" };`,
32
+ );
33
+ for (const alias of binding.exportNames.slice(1)) {
34
+ lines.push(`export const ${alias} = ${primaryName};`);
28
35
  }
29
36
  }
30
37
 
@@ -90,7 +90,12 @@ export function transformRouter(
90
90
  const closeParen = findMatchingParen(code, parenPos + 1);
91
91
  const callArgs = code.slice(parenPos + 1, closeParen);
92
92
 
93
- if (callArgs.includes("$$id")) continue;
93
+ // Skip ONLY a call we already injected into. The injected marker is the
94
+ // unique `$$routeNames: <var>` property; a bare `$$id` substring check
95
+ // would also match a user value/comment that merely contains the text
96
+ // "$$id" (e.g. `createRouter({ meta: { note: "see $$id docs" } })`),
97
+ // silently dropping named-route wiring for a legitimate config.
98
+ if (callArgs.includes(`$$routeNames: ${routeNamesVar}`)) continue;
94
99
 
95
100
  const sourceFilePath = absolutePath ?? filePath;
96
101
  const lineNumber = code.slice(0, callStart).split("\n").length;
@@ -762,7 +762,6 @@ ${lazyImports.join(",\n")}
762
762
  transformHandles(
763
763
  getBindings(code, fnNames),
764
764
  s,
765
- code,
766
765
  filePath,
767
766
  isBuild,
768
767
  ) || changed;
@@ -1,5 +1,6 @@
1
1
  import { parseAst, type Plugin } from "vite";
2
2
  import { VIRTUAL_IDS, getVirtualVersionContent } from "./virtual-entries.js";
3
+ import { hasUseClientDirective } from "../utils/directive-prologue.js";
3
4
 
4
5
  interface ClientModuleSignature {
5
6
  key: string;
@@ -16,6 +17,10 @@ function normalizeModuleId(id: string): string {
16
17
  function getClientModuleSignature(
17
18
  source: string,
18
19
  ): ClientModuleSignature | undefined {
20
+ // Same leading-directive sniff the rango HMR plugin uses (shared helper, so the
21
+ // two agree on what counts as a client module). A parse failure yields false.
22
+ if (!hasUseClientDirective(source)) return undefined;
23
+
19
24
  let program: any;
20
25
  try {
21
26
  program = parseAst(source, { lang: "tsx" });
@@ -23,23 +28,6 @@ function getClientModuleSignature(
23
28
  return undefined;
24
29
  }
25
30
 
26
- let isUseClient = false;
27
- for (const node of program.body ?? []) {
28
- if (
29
- node?.type === "ExpressionStatement" &&
30
- node.expression?.type === "Literal" &&
31
- typeof node.expression.value === "string"
32
- ) {
33
- if (node.expression.value === "use client") {
34
- isUseClient = true;
35
- }
36
- continue;
37
- }
38
- break;
39
- }
40
-
41
- if (!isUseClient) return undefined;
42
-
43
31
  const exports = new Set<string>();
44
32
  let hasDefault = false;
45
33
  let hasExportAll = false;
@@ -20,11 +20,16 @@ async function initializeApp() {
20
20
  createTemporaryReferenceSet,
21
21
  };
22
22
 
23
- await initBrowserApp({ rscStream, deps });
23
+ // initBrowserApp resolves the initial payload and returns the browser app
24
+ // context, including strictMode (default true) from createRouter. StrictMode
25
+ // is the default; createRouter({ strictMode: false }) ships the opt-out in the
26
+ // payload metadata. StrictMode emits no DOM, so toggling never changes markup.
27
+ const { strictMode } = await initBrowserApp({ rscStream, deps });
24
28
 
29
+ const app = createElement(Rango);
25
30
  hydrateRoot(
26
31
  document,
27
- createElement(StrictMode, null, createElement(Rango))
32
+ strictMode === false ? app : createElement(StrictMode, null, app)
28
33
  );
29
34
  }
30
35
 
@@ -79,6 +84,11 @@ export default function handler(request, env) {
79
84
  _handler = createRSCHandler({
80
85
  router,
81
86
  version: VERSION,
87
+ // Forward the router's CSP nonce provider. createRSCHandler reads the
88
+ // provider only from options.nonce; without this, createRouter({ nonce })
89
+ // is silently dropped on the Node preset (the Cloudflare path wires it via
90
+ // router.fetch). router.nonce is undefined when unconfigured, a safe no-op.
91
+ nonce: router.nonce,
82
92
  deps: {
83
93
  renderToReadableStream,
84
94
  decodeReply,
package/src/vite/rango.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginOption } from "vite";
1
+ import { type PluginOption } from "vite";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { exposeActionId } from "./plugins/expose-action-id.js";
@@ -37,6 +37,13 @@ import { createRangoDebugger, NS } from "./debug.js";
37
37
 
38
38
  const debugConfig = createRangoDebugger(NS.config);
39
39
 
40
+ // The leading-directive 'use client' sniff is shared with version-plugin's
41
+ // getClientModuleSignature so the two cannot drift. Imported for local use by the
42
+ // HMR transform below and re-exported because the E8 sniff test imports it from
43
+ // this module.
44
+ import { hasUseClientDirective } from "./utils/directive-prologue.js";
45
+ export { hasUseClientDirective };
46
+
40
47
  /**
41
48
  * Vite plugin for @rangojs/router.
42
49
  *
@@ -335,6 +342,12 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
335
342
  "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
336
343
  ),
337
344
  ],
345
+ // Vite 8 does not propagate the top-level optimizeDeps.exclude
346
+ // (set in config()) to non-client envs, so the rsc env must set
347
+ // it explicitly — mirroring the node ssr env and the cloudflare
348
+ // rsc env. Without it a strict-pnpm npm-installed app can try to
349
+ // pre-bundle the router's own subpath entries and fail.
350
+ exclude: excludeDeps,
338
351
  rolldownOptions: sharedRolldownOptions,
339
352
  },
340
353
  },
@@ -396,11 +409,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
396
409
 
397
410
  try {
398
411
  const source = readFileSync(file, "utf-8");
399
- const trimmed = source.trimStart();
400
- if (
401
- trimmed.startsWith('"use client"') ||
402
- trimmed.startsWith("'use client'")
403
- ) {
412
+ if (hasUseClientDirective(source)) {
404
413
  return [];
405
414
  }
406
415
  } catch {}