@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.
- package/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
package/src/testing/flight.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [...] }`).
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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>
|
package/src/theme/constants.ts
CHANGED
|
@@ -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
|
|
92
|
+
defaultTheme,
|
|
42
93
|
themes,
|
|
43
94
|
attribute: config.attribute ?? THEME_DEFAULTS.attribute,
|
|
44
95
|
storageKey: config.storageKey ?? THEME_DEFAULTS.storageKey,
|
|
45
|
-
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
|
-
|
|
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
|
-
|
|
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()
|
|
29
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 {}
|