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

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 (54) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +56 -14
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/fresh.ts +8 -9
  36. package/src/router/segment-resolution/helpers.ts +11 -10
  37. package/src/router/segment-resolution/loader-cache.ts +13 -0
  38. package/src/router/segment-resolution/revalidation.ts +4 -4
  39. package/src/router/segment-wrappers.ts +3 -0
  40. package/src/router/trie-matching.ts +74 -20
  41. package/src/router.ts +2 -2
  42. package/src/rsc/progressive-enhancement.ts +20 -0
  43. package/src/rsc/server-action.ts +124 -47
  44. package/src/search-params.ts +8 -6
  45. package/src/segment-system.tsx +7 -1
  46. package/src/server/cookie-parse.ts +32 -0
  47. package/src/server/handle-store.ts +14 -14
  48. package/src/server/request-context.ts +5 -26
  49. package/src/ssr/index.tsx +5 -4
  50. package/src/testing/render-handler.ts +11 -0
  51. package/src/vite/plugins/expose-id-utils.ts +77 -2
  52. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  53. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  54. package/src/vite/utils/prerender-utils.ts +1 -3
package/src/ssr/index.tsx CHANGED
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
  import { renderSegments } from "../segment-system.js";
3
- import { filterSegmentOrder } from "../browser/react/filter-segment-order.js";
3
+ import {
4
+ filterSegmentOrder,
5
+ filterRouteSegmentIds,
6
+ } from "../browser/react/filter-segment-order.js";
4
7
  import { ThemeProvider } from "../theme/ThemeProvider.js";
5
8
  import { NonceContext } from "../browser/react/nonce-context.js";
6
9
  import { NavigationStoreContext } from "../browser/react/context.js";
@@ -166,9 +169,7 @@ function createSsrEventController(opts: {
166
169
  const handleState = {
167
170
  data: opts.handleData ?? {},
168
171
  segmentOrder: filterSegmentOrder(rawMatched),
169
- routeSegmentIds: rawMatched.filter(
170
- (id) => !id.includes(".@") && !/D\d+\./.test(id),
171
- ),
172
+ routeSegmentIds: filterRouteSegmentIds(rawMatched),
172
173
  };
173
174
  const state: DerivedNavigationState = {
174
175
  state: "idle",
@@ -49,6 +49,8 @@ import {
49
49
  } from "./flight.js";
50
50
  import type { SegmentCacheStore } from "../cache/types.js";
51
51
  import type { CacheProfile } from "../cache/profile-registry.js";
52
+ import type { ThemeConfig } from "../theme/types.js";
53
+ import { resolveThemeConfig } from "../theme/constants.js";
52
54
  import {
53
55
  deserializeFlight,
54
56
  makeClientManifest,
@@ -114,6 +116,13 @@ export interface RenderHandlerOptions<TEnv = any> {
114
116
  * `"use cache: profileName"` resolution once a `cacheStore` is wired.
115
117
  */
116
118
  cacheProfiles?: Record<string, CacheProfile>;
119
+ /**
120
+ * Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
121
+ * or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert,
122
+ * mirroring an app with no theme configured. Pass one to exercise a handler
123
+ * that reads `ctx.theme` or writes the theme cookie via `ctx.setTheme(...)`.
124
+ */
125
+ theme?: ThemeConfig | true;
117
126
  }
118
127
 
119
128
  /** Result of {@link renderHandler}. */
@@ -225,6 +234,8 @@ export async function renderHandler<TEnv = any>(
225
234
  version: opts.stateCookie?.version,
226
235
  cacheStore: opts.cacheStore,
227
236
  cacheProfiles: opts.cacheProfiles,
237
+ themeConfig:
238
+ opts.theme === undefined ? undefined : resolveThemeConfig(opts.theme),
228
239
  });
229
240
 
230
241
  const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
@@ -243,6 +243,81 @@ export function findStatementEnd(code: string, pos: number): number {
243
243
  return i;
244
244
  }
245
245
 
246
- export function escapeRegExp(input: string): string {
247
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
246
+ export { escapeRegExp } from "../../regex-escape.js";
247
+
248
+ /**
249
+ * Given an index pointing just past a `create*` callee identifier, return the
250
+ * index of the call's opening `(`, accounting for an optional generic type
251
+ * argument list `<...>` with arbitrarily nested `<>`. Returns -1 when the next
252
+ * non-trivia token is neither `<` nor `(` (i.e. not a call site).
253
+ *
254
+ * Replaces the single-level `<[^>]*>` regex assumption, which stopped at the
255
+ * first `>` and so never reached `(` for `createRouter<Config<Env>>(`.
256
+ *
257
+ * Whitespace, comments, and strings between the callee and `(` are skipped via
258
+ * skipStringOrComment. The `<...>` scan only balances angle brackets; it does
259
+ * not attempt to parse full TS type syntax (sufficient for locating the paren).
260
+ */
261
+ export function findCallParenAfterGenerics(
262
+ code: string,
263
+ afterCalleeIndex: number,
264
+ ): number {
265
+ let i = afterCalleeIndex;
266
+ // Skip leading whitespace/comments/strings before `<` or `(`.
267
+ while (i < code.length) {
268
+ const skipped = skipStringOrComment(code, i);
269
+ if (skipped > i) {
270
+ i = skipped;
271
+ continue;
272
+ }
273
+ if (/\s/.test(code[i])) {
274
+ i++;
275
+ continue;
276
+ }
277
+ break;
278
+ }
279
+
280
+ if (i >= code.length) return -1;
281
+
282
+ if (code[i] === "(") return i;
283
+
284
+ if (code[i] === "<") {
285
+ let depth = 0;
286
+ while (i < code.length) {
287
+ const skipped = skipStringOrComment(code, i);
288
+ if (skipped > i) {
289
+ i = skipped;
290
+ continue;
291
+ }
292
+ const ch = code[i];
293
+ if (ch === "<") {
294
+ depth++;
295
+ } else if (ch === ">") {
296
+ depth--;
297
+ if (depth === 0) {
298
+ i++;
299
+ break;
300
+ }
301
+ }
302
+ i++;
303
+ }
304
+ if (depth !== 0) return -1;
305
+ // After the balanced generic list, skip trivia and expect `(`.
306
+ while (i < code.length) {
307
+ const skipped = skipStringOrComment(code, i);
308
+ if (skipped > i) {
309
+ i = skipped;
310
+ continue;
311
+ }
312
+ if (/\s/.test(code[i])) {
313
+ i++;
314
+ continue;
315
+ }
316
+ break;
317
+ }
318
+ if (i < code.length && code[i] === "(") return i;
319
+ return -1;
320
+ }
321
+
322
+ return -1;
248
323
  }
@@ -5,6 +5,7 @@ import {
5
5
  findStatementEnd,
6
6
  buildExportMap,
7
7
  escapeRegExp,
8
+ findCallParenAfterGenerics,
8
9
  } from "../expose-id-utils.js";
9
10
  import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
10
11
  import type { CreateExportBinding } from "./types.js";
@@ -50,18 +51,42 @@ export function isExportOnlyFile(
50
51
  return true;
51
52
  }
52
53
 
54
+ // Matches the callee identifier only. The optional generic argument list
55
+ // (which may be nested, e.g. createLoader<A<B>>(...)) and the call paren are
56
+ // resolved per match via findCallParenAfterGenerics, so a nested `>` no longer
57
+ // defeats the scan the way `<[^>]*>` did (it stopped at the first `>`).
53
58
  function createCallPattern(fnNames: string[]): RegExp {
54
- return new RegExp(
55
- `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
56
- "g",
59
+ return new RegExp(`\\b(?:${fnNames.map(escapeRegExp).join("|")})\\b`, "g");
60
+ }
61
+
62
+ /**
63
+ * Byte offsets of every create*-call site in real code: a callee-identifier
64
+ * match that is actually followed by a call `(` (after an optional nested
65
+ * generic list). Non-call references (type positions, the import specifier
66
+ * itself) yield -1 from findCallParenAfterGenerics and are dropped.
67
+ */
68
+ function createCallStartIndices(code: string, fnNames: string[]): number[] {
69
+ return codeMatchIndices(code, createCallPattern(fnNames)).filter(
70
+ (index) =>
71
+ findCallParenAfterGenerics(
72
+ code,
73
+ index + matchedNameLength(code, index),
74
+ ) !== -1,
57
75
  );
58
76
  }
59
77
 
78
+ // Length of the identifier match at `index` (run of identifier chars).
79
+ function matchedNameLength(code: string, index: number): number {
80
+ let i = index;
81
+ while (i < code.length && /[A-Za-z0-9_$]/.test(code[i])) i++;
82
+ return i - index;
83
+ }
84
+
60
85
  export function countCreateCallsForNames(
61
86
  code: string,
62
87
  fnNames: string[],
63
88
  ): number {
64
- return codeMatchIndices(code, createCallPattern(fnNames)).length;
89
+ return createCallStartIndices(code, fnNames).length;
65
90
  }
66
91
 
67
92
  export function offsetToLineColumn(
@@ -86,7 +111,7 @@ export function findUnsupportedCreateCallSites(
86
111
  supportedBindings: CreateExportBinding[],
87
112
  ): Array<{ line: number; column: number }> {
88
113
  const supported = new Set(supportedBindings.map((b) => b.callExprStart));
89
- return codeMatchIndices(code, createCallPattern(fnNames))
114
+ return createCallStartIndices(code, fnNames)
90
115
  .filter((index) => !supported.has(index))
91
116
  .map((index) => offsetToLineColumn(code, index));
92
117
  }
@@ -2,26 +2,65 @@ import type { Plugin } from "vite";
2
2
  import MagicString from "magic-string";
3
3
  import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
- import { normalizePath, findMatchingParen } from "../expose-id-utils.js";
6
- import { getImportedFnNames } from "./export-analysis.js";
5
+ import {
6
+ normalizePath,
7
+ findMatchingParen,
8
+ findCallParenAfterGenerics,
9
+ skipStringOrComment,
10
+ } from "../expose-id-utils.js";
11
+ import { escapeRegExp } from "../../../regex-escape.js";
12
+ import {
13
+ getImportedFnNames,
14
+ buildUnsupportedShapeWarning,
15
+ } from "./export-analysis.js";
7
16
  import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
8
17
  import { createRangoDebugger, createCounter, NS } from "../../debug.js";
9
18
 
10
19
  const debug = createRangoDebugger(NS.transform);
11
20
 
21
+ /**
22
+ * Advance past leading whitespace and a single leading line/block comment so a
23
+ * `createRouter(/* opts *\/ { ... })` call still resolves to its `{`. Only the
24
+ * first comment-or-whitespace run is skipped (enough for the common prefix
25
+ * case); a non-trivia token after it stops the scan.
26
+ */
27
+ function skipLeadingTrivia(code: string, start: number, end: number): number {
28
+ let i = start;
29
+ while (i < end) {
30
+ const skipped = skipStringOrComment(code, i);
31
+ // skipStringOrComment only advances for strings/comments, not whitespace.
32
+ if (skipped > i) {
33
+ i = skipped;
34
+ continue;
35
+ }
36
+ if (/\s/.test(code[i])) {
37
+ i++;
38
+ continue;
39
+ }
40
+ break;
41
+ }
42
+ return i;
43
+ }
44
+
12
45
  export function transformRouter(
13
46
  code: string,
14
47
  filePath: string,
15
48
  routerFnNames: string[],
16
49
  absolutePath?: string,
50
+ warn?: (message: string) => void,
17
51
  ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
52
+ // Match only the callee identifier; the generic list (which may be nested,
53
+ // e.g. createRouter<Config<Env>>(...)) and the opening paren are located
54
+ // separately via findCallParenAfterGenerics so a nested `>` does not defeat
55
+ // the scan (a `<[^>]*>` regex stopped at the first `>`).
18
56
  const pat = new RegExp(
19
- `\\b(?:${routerFnNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\s*(?:<[^>]*>)?\\s*\\(`,
57
+ `\\b(?:${routerFnNames.map(escapeRegExp).join("|")})\\b`,
20
58
  "g",
21
59
  );
22
60
  let match: RegExpExecArray | null;
23
61
  const s = new MagicString(code);
24
62
  let changed = false;
63
+ const unsupportedSites: Array<{ line: number; column: number }> = [];
25
64
 
26
65
  // Compute the import path for the generated route names file.
27
66
  // filePath is relative to project root (e.g., "src/router.tsx")
@@ -40,32 +79,58 @@ export function transformRouter(
40
79
  while ((match = pat.exec(code)) !== null) {
41
80
  if (!codeOffsets.has(match.index)) continue;
42
81
  const callStart = match.index;
43
- const parenPos = match.index + match[0].length - 1;
82
+ const calleeEnd = match.index + match[0].length;
83
+
84
+ // Resolve the call's opening paren, skipping an optional (possibly nested)
85
+ // generic argument list. A non-call reference (e.g. a bare identifier or a
86
+ // type position) yields -1 and is skipped.
87
+ const parenPos = findCallParenAfterGenerics(code, calleeEnd);
88
+ if (parenPos === -1) continue;
44
89
 
45
90
  const closeParen = findMatchingParen(code, parenPos + 1);
46
91
  const callArgs = code.slice(parenPos + 1, closeParen);
47
92
 
48
93
  if (callArgs.includes("$$id")) continue;
49
94
 
95
+ const sourceFilePath = absolutePath ?? filePath;
50
96
  const lineNumber = code.slice(0, callStart).split("\n").length;
51
97
  const hash = createHash("sha256")
52
98
  .update(`${filePath}:${lineNumber}`)
53
99
  .digest("hex")
54
100
  .slice(0, 8);
55
-
56
- changed = true;
57
- const sourceFilePath = absolutePath ?? filePath;
58
101
  const injected = ` $$id: "${hash}", $$sourceFile: "${sourceFilePath}", $$routeNames: ${routeNamesVar},`;
59
102
 
60
- const afterParen = callArgs.trimStart();
61
- if (afterParen.startsWith("{")) {
62
- const bracePos = code.indexOf("{", parenPos + 1);
63
- s.appendRight(bracePos + 1, injected);
64
- } else if (afterParen.startsWith(")")) {
103
+ // Skip a leading comment/whitespace run so `createRouter(/* c *\/ {...})`
104
+ // and a newline-then-comment prefix still resolve to the object literal.
105
+ // findMatchingParen returns one past the close `)`, so the matching paren
106
+ // sits at closeParen - 1 and empty args resolve to that `)`.
107
+ const argsContentStart = skipLeadingTrivia(code, parenPos + 1, closeParen);
108
+ const firstArgChar = code[argsContentStart];
109
+
110
+ if (firstArgChar === "{") {
111
+ changed = true;
112
+ s.appendRight(argsContentStart + 1, injected);
113
+ } else if (argsContentStart >= closeParen - 1) {
114
+ // Empty args: createRouter(). Wrap a fresh config object.
115
+ changed = true;
65
116
  s.appendRight(parenPos + 1, `{${injected} }`);
117
+ } else {
118
+ // Unsupported argument shape (bare identifier, spread, call, etc.). No
119
+ // stable $$id can be injected here. Record it so the plugin can warn —
120
+ // and crucially do NOT mark changed, so a dead named-routes.gen import is
121
+ // not prepended for a call we never touched.
122
+ const lastNl = code.lastIndexOf("\n", callStart - 1);
123
+ const column = callStart - (lastNl + 1) + 1;
124
+ unsupportedSites.push({ line: lineNumber, column });
66
125
  }
67
126
  }
68
127
 
128
+ if (unsupportedSites.length > 0 && warn) {
129
+ warn(
130
+ buildUnsupportedShapeWarning(filePath, "createRouter", unsupportedSites),
131
+ );
132
+ }
133
+
69
134
  if (!changed) return null;
70
135
 
71
136
  s.prepend(
@@ -110,11 +175,16 @@ export function exposeRouterId(): Plugin {
110
175
  try {
111
176
  const filePath = normalizePath(path.relative(projectRoot, id));
112
177
  const routerFnNames = getImportedFnNames(code, "createRouter");
178
+ const warn =
179
+ typeof this.warn === "function"
180
+ ? (message: string) => this.warn(message)
181
+ : undefined;
113
182
  return transformRouter(
114
183
  code,
115
184
  filePath,
116
185
  routerFnNames,
117
186
  normalizePath(id),
187
+ warn,
118
188
  );
119
189
  } finally {
120
190
  counter?.record(id, performance.now() - start);
@@ -9,9 +9,7 @@ import {
9
9
  } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
 
12
- export function escapeRegExp(str: string): string {
13
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
- }
12
+ import { escapeRegExp } from "../../regex-escape.js";
15
13
 
16
14
  export function encodePathParam(value: unknown): string {
17
15
  return String(value)