@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114
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 +74 -3
- package/dist/vite/index.js +133 -18
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +35 -24
- package/skills/caching/SKILL.md +115 -7
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/hooks/SKILL.md +40 -22
- package/skills/links/SKILL.md +10 -10
- package/skills/loader/SKILL.md +3 -3
- package/skills/rango/SKILL.md +16 -10
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +85 -3
- package/src/browser/react/location-state-shared.ts +93 -3
- package/src/browser/react/use-reverse.ts +19 -12
- package/src/build/route-types/per-module-writer.ts +4 -1
- package/src/build/route-types/router-processing.ts +14 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +49 -6
- package/src/handle.ts +3 -5
- package/src/loader-store.ts +62 -25
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/reverse.ts +16 -13
- package/src/route-definition/dsl-helpers.ts +5 -2
- package/src/route-definition/helpers-types.ts +31 -10
- package/src/router/loader-resolution.ts +16 -2
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/router-options.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +17 -4
- package/src/router/segment-resolution/revalidation.ts +17 -4
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/types.ts +8 -0
- package/src/router.ts +2 -0
- package/src/segment-system.tsx +59 -10
- package/src/server/context.ts +26 -0
- package/src/server/cookie-store.ts +28 -4
- package/src/types/handler-context.ts +5 -2
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +9 -1
- package/src/use-loader.tsx +89 -42
- package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
- package/src/vite/plugins/expose-internal-ids.ts +12 -4
- package/src/vite/plugins/use-cache-transform.ts +12 -10
- package/src/vite/router-discovery.ts +14 -2
|
@@ -56,6 +56,15 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
|
|
|
56
56
|
/** Header storing cache status: HIT | REVALIDATING */
|
|
57
57
|
export const CACHE_STATUS_HEADER = "x-edge-cache-status";
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Header stashing the route author's original Cache-Control on L1 document
|
|
61
|
+
* entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
|
|
62
|
+
* `max-age` so the CF Cache API retains the entry across the whole SWR window;
|
|
63
|
+
* getResponse restores this original value before serving so the client and any
|
|
64
|
+
* upstream CDN see the author's intended directive, not the internal edge TTL.
|
|
65
|
+
*/
|
|
66
|
+
const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
|
|
67
|
+
|
|
59
68
|
/**
|
|
60
69
|
* Maximum age in seconds for REVALIDATING status before allowing new revalidation.
|
|
61
70
|
* After this period, a stale entry in REVALIDATING status will trigger revalidation again.
|
|
@@ -182,7 +191,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
|
|
|
182
191
|
* Cache version string override. When this changes, all cached entries are
|
|
183
192
|
* effectively invalidated (new keys won't match old entries).
|
|
184
193
|
*
|
|
185
|
-
* Defaults to the auto-generated VERSION from
|
|
194
|
+
* Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
|
|
186
195
|
* Only set this if you need a custom versioning strategy.
|
|
187
196
|
*/
|
|
188
197
|
version?: string;
|
|
@@ -419,7 +428,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
419
428
|
}
|
|
420
429
|
|
|
421
430
|
// L2: persist to KV
|
|
422
|
-
this.kvSetSegment(key, data, staleAt, totalTtl);
|
|
431
|
+
this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
|
|
423
432
|
} catch (error) {
|
|
424
433
|
console.error("[CFCacheStore] set failed:", error);
|
|
425
434
|
}
|
|
@@ -478,7 +487,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
478
487
|
const isStale = staleAt > 0 && Date.now() > staleAt;
|
|
479
488
|
|
|
480
489
|
return {
|
|
481
|
-
response,
|
|
490
|
+
response: this.toClientResponse(response),
|
|
482
491
|
shouldRevalidate: isStale,
|
|
483
492
|
};
|
|
484
493
|
} catch (error) {
|
|
@@ -487,6 +496,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
487
496
|
}
|
|
488
497
|
}
|
|
489
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Strip internal edge headers and restore the author's Cache-Control before a
|
|
501
|
+
* cached document Response is served to a client. L1 entries carry the
|
|
502
|
+
* internal staleness/status headers and a rewritten Cache-Control; none of
|
|
503
|
+
* those should reach the browser or an upstream CDN.
|
|
504
|
+
*/
|
|
505
|
+
private toClientResponse(response: Response): Response {
|
|
506
|
+
const headers = new Headers(response.headers);
|
|
507
|
+
const originalCacheControl = headers.get(CACHE_ORIG_CC_HEADER);
|
|
508
|
+
if (originalCacheControl !== null) {
|
|
509
|
+
headers.set("Cache-Control", originalCacheControl);
|
|
510
|
+
} else {
|
|
511
|
+
headers.delete("Cache-Control");
|
|
512
|
+
}
|
|
513
|
+
headers.delete(CACHE_ORIG_CC_HEADER);
|
|
514
|
+
headers.delete(CACHE_STALE_AT_HEADER);
|
|
515
|
+
headers.delete(CACHE_STATUS_HEADER);
|
|
516
|
+
return new Response(response.body, {
|
|
517
|
+
status: response.status,
|
|
518
|
+
statusText: response.statusText,
|
|
519
|
+
headers,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
490
523
|
/**
|
|
491
524
|
* Store a Response with TTL and optional SWR window (for document-level caching).
|
|
492
525
|
* When KV is configured, also persists to L2.
|
|
@@ -513,8 +546,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
513
546
|
: [null, null]
|
|
514
547
|
: [response.body, null];
|
|
515
548
|
|
|
516
|
-
// Clone and add cache headers
|
|
549
|
+
// Clone and add cache headers. The author's Cache-Control is stashed and
|
|
550
|
+
// replaced with a long max-age so the CF Cache API holds the entry across
|
|
551
|
+
// the SWR window; getResponse restores the original before serving.
|
|
517
552
|
const headers = new Headers(response.headers);
|
|
553
|
+
const originalCacheControl = response.headers.get("Cache-Control");
|
|
554
|
+
if (originalCacheControl !== null) {
|
|
555
|
+
headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
|
|
556
|
+
}
|
|
518
557
|
headers.set("Cache-Control", `public, max-age=${totalTtl}`);
|
|
519
558
|
headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
|
|
520
559
|
|
|
@@ -764,13 +803,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
764
803
|
data: CachedEntryData,
|
|
765
804
|
staleAt: number,
|
|
766
805
|
totalTtl: number,
|
|
806
|
+
swrWindow: number,
|
|
767
807
|
): void {
|
|
768
808
|
// KV requires expirationTtl >= 60s. Skip write for short-lived entries.
|
|
769
809
|
if (!this.kv || !this.waitUntil || totalTtl < 60) return;
|
|
770
810
|
|
|
771
811
|
const kvKey = this.toKVKey(key);
|
|
772
|
-
const
|
|
773
|
-
const expiresAt = staleAt + swrWindow;
|
|
812
|
+
const expiresAt = staleAt + swrWindow * 1000;
|
|
774
813
|
|
|
775
814
|
this.waitUntil(async () => {
|
|
776
815
|
try {
|
|
@@ -937,6 +976,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
937
976
|
const request = this.keyToRequest(`doc:${key}`);
|
|
938
977
|
|
|
939
978
|
const headers = new Headers(envelope.hd);
|
|
979
|
+
const originalCacheControl = headers.get("Cache-Control");
|
|
980
|
+
if (originalCacheControl !== null) {
|
|
981
|
+
headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
|
|
982
|
+
}
|
|
940
983
|
headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
|
|
941
984
|
headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
|
|
942
985
|
|
package/src/handle.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Handle definition for accumulating data across route segments.
|
|
3
5
|
*
|
|
@@ -96,11 +98,7 @@ export function createHandle<TData, TAccumulated = TData[]>(
|
|
|
96
98
|
const handleId = __injectedId ?? "";
|
|
97
99
|
|
|
98
100
|
if (!handleId && process.env.NODE_ENV === "development") {
|
|
99
|
-
throw
|
|
100
|
-
"[rango] Handle is missing $$id. " +
|
|
101
|
-
"Make sure the exposeInternalIds Vite plugin is enabled and " +
|
|
102
|
-
"the handle is exported with: export const MyHandle = createHandle(...)",
|
|
103
|
-
);
|
|
101
|
+
throw missingInjectedIdError("Handle", "createHandle");
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
const collectFn =
|
package/src/loader-store.ts
CHANGED
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
|
|
37
37
|
export interface LoaderEntry<T = unknown> {
|
|
38
38
|
readonly value: T | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Whether a load has committed a value to this bucket. Distinguishes a
|
|
41
|
+
* committed `null`/`undefined` result from "never loaded", so a loader that
|
|
42
|
+
* resolves to a falsy value is not mistaken for an empty bucket and is not
|
|
43
|
+
* overridden by the server-seeded context value.
|
|
44
|
+
*/
|
|
45
|
+
readonly hasValue: boolean;
|
|
39
46
|
readonly error: Error | null;
|
|
40
47
|
readonly isLoading: boolean;
|
|
41
48
|
/** Identifies the request that produced this snapshot. 0 means "no request". */
|
|
@@ -44,6 +51,7 @@ export interface LoaderEntry<T = unknown> {
|
|
|
44
51
|
|
|
45
52
|
const EMPTY_SNAPSHOT: LoaderEntry = Object.freeze({
|
|
46
53
|
value: undefined,
|
|
54
|
+
hasValue: false,
|
|
47
55
|
error: null,
|
|
48
56
|
isLoading: false,
|
|
49
57
|
requestId: 0,
|
|
@@ -67,14 +75,15 @@ export interface SubscribeOptions {
|
|
|
67
75
|
*/
|
|
68
76
|
ephemeral?: boolean;
|
|
69
77
|
/**
|
|
70
|
-
* Cross-loader refresh group name. Tags this bucket so `
|
|
71
|
-
* can refresh it alongside buckets of other loaders.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
78
|
+
* Cross-loader refresh group name(s). Tags this bucket so `refreshGroups(name)`
|
|
79
|
+
* can refresh it alongside buckets of other loaders. A bucket may be tagged
|
|
80
|
+
* with several group names at once (pass an array); it is then refreshed when
|
|
81
|
+
* ANY of its groups is refreshed. Group membership follows subscriber presence:
|
|
82
|
+
* a bucket leaves a group when that group's last subscriber unsubscribes.
|
|
74
83
|
*/
|
|
75
|
-
group?: string;
|
|
84
|
+
group?: string | string[];
|
|
76
85
|
/**
|
|
77
|
-
* Plain-GET refresh thunk used by `
|
|
86
|
+
* Plain-GET refresh thunk used by `refreshGroups`. Provided alongside `group`.
|
|
78
87
|
* Refreshes this bucket in place (no params/body) and rejects on failure.
|
|
79
88
|
*/
|
|
80
89
|
refetch?: () => Promise<void>;
|
|
@@ -102,20 +111,31 @@ interface InternalEntry {
|
|
|
102
111
|
/**
|
|
103
112
|
* Cross-loader refresh groups this bucket belongs to, mapped to the number of
|
|
104
113
|
* current subscribers that requested each group. A bucket can be in several
|
|
105
|
-
* groups at once (
|
|
106
|
-
* different
|
|
107
|
-
* subscribe/unsubscribe order.
|
|
114
|
+
* groups at once (one read tagged with multiple names, or different subscribers
|
|
115
|
+
* tagging the same shared bucket with different names); refcounting keeps
|
|
116
|
+
* membership independent of subscribe/unsubscribe order.
|
|
108
117
|
*/
|
|
109
118
|
groups: Map<string, number>;
|
|
110
|
-
/** Plain-GET refresh thunk for `
|
|
119
|
+
/** Plain-GET refresh thunk for `refreshGroups`, set while in any group. */
|
|
111
120
|
refetch: (() => Promise<void>) | undefined;
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a group tag option (`undefined | string | string[]`) to a deduped
|
|
125
|
+
* list of names. Deduping keeps a single subscriber from being counted more than
|
|
126
|
+
* once in one group when the caller passes a repeated name.
|
|
127
|
+
*/
|
|
128
|
+
function normalizeGroups(group: string | string[] | undefined): string[] {
|
|
129
|
+
if (group === undefined) return [];
|
|
130
|
+
if (typeof group === "string") return [group];
|
|
131
|
+
return [...new Set(group)];
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
export class LoaderStore {
|
|
115
135
|
private readonly entries = new Map<string, InternalEntry>();
|
|
116
136
|
/** loader.$$id -> set of bucket keys, so clearFamily() can reach every bucket. */
|
|
117
137
|
private readonly families = new Map<string, Set<string>>();
|
|
118
|
-
/** refresh group name -> set of bucket keys, for
|
|
138
|
+
/** refresh group name -> set of bucket keys, for refreshGroups(). */
|
|
119
139
|
private readonly groups = new Map<string, Set<string>>();
|
|
120
140
|
|
|
121
141
|
private getOrCreate(bucketKey: string): InternalEntry {
|
|
@@ -163,8 +183,11 @@ export class LoaderStore {
|
|
|
163
183
|
e.loaderId = loaderId;
|
|
164
184
|
this.registerFamily(loaderId, bucketKey);
|
|
165
185
|
if (options?.ephemeral !== true) e.sticky = true;
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
// Normalize the group tag(s) to a deduped list so one subscriber is counted
|
|
187
|
+
// once per distinct group, and subscribe/unsubscribe stay symmetric (the
|
|
188
|
+
// same list drives both addToGroup and releaseGroup).
|
|
189
|
+
const groups = normalizeGroups(options?.group);
|
|
190
|
+
for (const group of groups) {
|
|
168
191
|
this.addToGroup(group, bucketKey, e, options?.refetch);
|
|
169
192
|
}
|
|
170
193
|
// A fresh subscriber means the bucket is wanted again: cancel any pending
|
|
@@ -174,11 +197,11 @@ export class LoaderStore {
|
|
|
174
197
|
e.listeners.add(cb);
|
|
175
198
|
return () => {
|
|
176
199
|
e.listeners.delete(cb);
|
|
177
|
-
// Group membership is refcounted per subscriber so
|
|
200
|
+
// Group membership is refcounted per subscriber so refreshGroups() never
|
|
178
201
|
// fetches for an unmounted reader, and a bucket shared by subscribers in
|
|
179
202
|
// different groups stays in each group until ALL of that group's
|
|
180
203
|
// subscribers have left (order-independent).
|
|
181
|
-
|
|
204
|
+
for (const group of groups) this.releaseGroup(group, bucketKey, e);
|
|
182
205
|
this.maybeScheduleRefcountClear(bucketKey, e);
|
|
183
206
|
};
|
|
184
207
|
}
|
|
@@ -232,17 +255,27 @@ export class LoaderStore {
|
|
|
232
255
|
}
|
|
233
256
|
|
|
234
257
|
/**
|
|
235
|
-
* Refresh every currently-mounted bucket
|
|
236
|
-
* a plain GET (no params/body).
|
|
237
|
-
* of
|
|
238
|
-
*
|
|
239
|
-
*
|
|
258
|
+
* Refresh every currently-mounted bucket tagged with ANY of the given group
|
|
259
|
+
* names, with a plain GET (no params/body). Accepts a single name or an array
|
|
260
|
+
* of names. A bucket that belongs to more than one of the named groups is
|
|
261
|
+
* refreshed exactly once (members are unioned and deduped by bucket key), as
|
|
262
|
+
* are multiple reads of one bucket. Resolves when all refreshes settle; rejects
|
|
263
|
+
* with an `AggregateError` of the failures if any member fails — each failing
|
|
264
|
+
* member also records its error on its own snapshot.
|
|
240
265
|
*/
|
|
241
|
-
async
|
|
242
|
-
const
|
|
243
|
-
|
|
266
|
+
async refreshGroups(groups: string | string[]): Promise<void> {
|
|
267
|
+
const names = typeof groups === "string" ? [groups] : groups;
|
|
268
|
+
// Union the member buckets across every named group, deduped by key, so a
|
|
269
|
+
// bucket tagged into two of the requested groups is fetched a single time.
|
|
270
|
+
const buckets = new Set<string>();
|
|
271
|
+
for (const name of names) {
|
|
272
|
+
const members = this.groups.get(name);
|
|
273
|
+
if (!members) continue;
|
|
274
|
+
for (const bucketKey of members) buckets.add(bucketKey);
|
|
275
|
+
}
|
|
276
|
+
if (buckets.size === 0) return;
|
|
244
277
|
const thunks: Array<() => Promise<void>> = [];
|
|
245
|
-
for (const bucketKey of
|
|
278
|
+
for (const bucketKey of buckets) {
|
|
246
279
|
const e = this.entries.get(bucketKey);
|
|
247
280
|
if (!e || e.listeners.size === 0 || !e.refetch) continue;
|
|
248
281
|
thunks.push(e.refetch);
|
|
@@ -253,9 +286,10 @@ export class LoaderStore {
|
|
|
253
286
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
254
287
|
.map((r) => r.reason);
|
|
255
288
|
if (reasons.length > 0) {
|
|
289
|
+
const label = names.map((n) => `"${n}"`).join(", ");
|
|
256
290
|
throw new AggregateError(
|
|
257
291
|
reasons,
|
|
258
|
-
`
|
|
292
|
+
`refreshGroups(${label}) had ${reasons.length} failure(s)`,
|
|
259
293
|
);
|
|
260
294
|
}
|
|
261
295
|
}
|
|
@@ -346,6 +380,7 @@ export class LoaderStore {
|
|
|
346
380
|
if (e.snapshot.isLoading && e.snapshot.error === null) return;
|
|
347
381
|
e.snapshot = Object.freeze({
|
|
348
382
|
value: e.snapshot.value,
|
|
383
|
+
hasValue: e.snapshot.hasValue,
|
|
349
384
|
error: null,
|
|
350
385
|
isLoading: true,
|
|
351
386
|
requestId,
|
|
@@ -363,6 +398,7 @@ export class LoaderStore {
|
|
|
363
398
|
if (!e || requestId !== e.latestRequestId) return;
|
|
364
399
|
e.snapshot = Object.freeze({
|
|
365
400
|
value,
|
|
401
|
+
hasValue: true,
|
|
366
402
|
error: null,
|
|
367
403
|
isLoading: false,
|
|
368
404
|
requestId,
|
|
@@ -381,6 +417,7 @@ export class LoaderStore {
|
|
|
381
417
|
if (!e || requestId !== e.latestRequestId) return;
|
|
382
418
|
e.snapshot = Object.freeze({
|
|
383
419
|
value: e.snapshot.value,
|
|
420
|
+
hasValue: e.snapshot.hasValue,
|
|
384
421
|
error,
|
|
385
422
|
isLoading: false,
|
|
386
423
|
requestId,
|
package/src/loader.rsc.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
registerFetchableLoader,
|
|
22
22
|
getFetchableLoader,
|
|
23
23
|
} from "./server/fetchable-loader-store.js";
|
|
24
|
+
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
24
25
|
|
|
25
26
|
export { getFetchableLoader };
|
|
26
27
|
|
|
@@ -53,11 +54,7 @@ export function createLoader<T>(
|
|
|
53
54
|
const loaderId = __injectedId || "";
|
|
54
55
|
|
|
55
56
|
if (!loaderId && process.env.NODE_ENV === "development") {
|
|
56
|
-
throw
|
|
57
|
-
"[rango] Loader is missing $$id. " +
|
|
58
|
-
"Make sure the exposeInternalIds Vite plugin is enabled and " +
|
|
59
|
-
"the loader is exported with: export const MyLoader = createLoader(...)",
|
|
60
|
-
);
|
|
57
|
+
throw missingInjectedIdError("Loader", "createLoader");
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
// If not fetchable, store fn in registry (for SSR ctx.use() resolution)
|
package/src/loader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @rangojs/router/loader (client version)
|
|
3
3
|
*
|
|
4
4
|
* Client-only stub for createLoader. Returns a minimal loader definition
|
|
5
5
|
* ({ __brand, $$id }) that can be passed to hooks like useLoader.
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
LoaderDefinition,
|
|
19
19
|
LoaderFn,
|
|
20
20
|
} from "./types.js";
|
|
21
|
+
import { missingInjectedIdError } from "./missing-id-error.js";
|
|
21
22
|
|
|
22
23
|
// Overload 1: With function only (not fetchable)
|
|
23
24
|
export function createLoader<T>(
|
|
@@ -38,10 +39,6 @@ export function createLoader<T>(
|
|
|
38
39
|
|
|
39
40
|
// Implementation - client stub that just returns the loader definition
|
|
40
41
|
// The $$id parameter is injected by Vite plugin, not user-provided
|
|
41
|
-
//
|
|
42
|
-
// NOTE: For export-only loader files, the Vite plugin replaces the entire
|
|
43
|
-
// file with object literals (bypassing this function). This function only
|
|
44
|
-
// runs when loaders are in mixed files (not export-only).
|
|
45
42
|
export function createLoader<T>(
|
|
46
43
|
_fn: LoaderFn<T, Record<string, string | undefined>, any>,
|
|
47
44
|
_fetchable?: true | FetchableLoaderOptions,
|
|
@@ -50,11 +47,7 @@ export function createLoader<T>(
|
|
|
50
47
|
const loaderId = __injectedId || "";
|
|
51
48
|
|
|
52
49
|
if (!loaderId && process.env.NODE_ENV === "development") {
|
|
53
|
-
throw
|
|
54
|
-
"[rango] Loader is missing $$id. " +
|
|
55
|
-
"Make sure the exposeInternalIds Vite plugin is enabled and " +
|
|
56
|
-
"the loader is exported with: export const MyLoader = createLoader(...)",
|
|
57
|
-
);
|
|
50
|
+
throw missingInjectedIdError("Loader", "createLoader");
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
return {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Builds the error thrown when a create*() call (createLoader / createHandle)
|
|
2
|
+
// reaches runtime without an injected $$id. The exposeInternalIds Vite transform
|
|
3
|
+
// injects $$id only for an EXPORTED const declaration, so a non-exported const,
|
|
4
|
+
// an `export let/var`, or an inline create*() call gets none. Previously this
|
|
5
|
+
// failed with a terse message and no source location; this helper adds the
|
|
6
|
+
// offending call site (best-effort, from the stack) and actionable guidance.
|
|
7
|
+
//
|
|
8
|
+
// The "<Kind> is missing $$id" prefix is preserved so existing tests and any
|
|
9
|
+
// log scrapers keep matching. Dev-only: the call sites guard on
|
|
10
|
+
// process.env.NODE_ENV === "development", so production builds fold the branch
|
|
11
|
+
// away and tree-shake this module out.
|
|
12
|
+
|
|
13
|
+
// create*() implementation files to skip when locating the user's call site.
|
|
14
|
+
const SELF_FILES = new Set([
|
|
15
|
+
"missing-id-error",
|
|
16
|
+
"loader",
|
|
17
|
+
"loader.rsc",
|
|
18
|
+
"handle",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Best-effort "path:line:column" of the user's create*() call, parsed from the
|
|
23
|
+
* current stack. Skips @rangojs/router internals and node_modules. Returns
|
|
24
|
+
* undefined if nothing usable is found (stack parsing is inherently fragile).
|
|
25
|
+
*/
|
|
26
|
+
function findUserCallSite(): string | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const stack = new Error().stack;
|
|
29
|
+
if (!stack) return undefined;
|
|
30
|
+
for (const frame of stack.split("\n").slice(1)) {
|
|
31
|
+
const m = frame.match(
|
|
32
|
+
/(?:\(|@|\s)(?:file:\/\/)?((?:\/|[A-Za-z]:[\\/])[^()\s]+?\.(?:ts|tsx|js|jsx|mts|cts)):(\d+):(\d+)\)?/,
|
|
33
|
+
);
|
|
34
|
+
if (!m) continue;
|
|
35
|
+
const path = m[1];
|
|
36
|
+
if (path.includes("node_modules") || path.includes("@rangojs/router")) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const base = path
|
|
40
|
+
.split(/[\\/]/)
|
|
41
|
+
.pop()!
|
|
42
|
+
.replace(/\.(?:ts|tsx|js|jsx|mts|cts)$/, "");
|
|
43
|
+
if (SELF_FILES.has(base)) continue;
|
|
44
|
+
return `${path}:${m[2]}:${m[3]}`;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// best-effort only
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function missingInjectedIdError(
|
|
53
|
+
kind: "Loader" | "Handle",
|
|
54
|
+
fnName: "createLoader" | "createHandle",
|
|
55
|
+
): Error {
|
|
56
|
+
const site = findUserCallSite();
|
|
57
|
+
const at = site ? ` (created at ${site})` : "";
|
|
58
|
+
return new Error(
|
|
59
|
+
`[rango] ${kind} is missing $$id${at}.\n` +
|
|
60
|
+
`The @rangojs/router:expose-internal-ids Vite transform injects ${fnName}()'s ` +
|
|
61
|
+
`stable $$id from an EXPORTED const declaration only:\n` +
|
|
62
|
+
` export const X = ${fnName}(...)\n` +
|
|
63
|
+
` const X = ${fnName}(...); export { X }\n` +
|
|
64
|
+
`A non-exported const, an \`export let/var\`, or an inline ${fnName}(...) ` +
|
|
65
|
+
`call gets no $$id — export it as \`export const\`. (A matching ` +
|
|
66
|
+
`"Unsupported ${fnName} shape" warning names the exact file:line.)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/reverse.ts
CHANGED
|
@@ -231,47 +231,50 @@ export type LocalReverseParams<TPattern extends string> =
|
|
|
231
231
|
};
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
* Type-safe local reverse function
|
|
234
|
+
* Type-safe local reverse function.
|
|
235
235
|
*
|
|
236
236
|
* Returned by `useReverse(routes)` on the client. The route map is the
|
|
237
237
|
* exposure boundary (a generated `routes` from a `urls()` module) and the
|
|
238
|
-
* scope is implicit from that import
|
|
239
|
-
*
|
|
238
|
+
* scope is implicit from that import. Names may be written with or without a
|
|
239
|
+
* leading dot — `reverse("post")` and `reverse(".post")` are identical. The dot
|
|
240
|
+
* is a cosmetic readability convention (and parity with `ctx.reverse(".name")`);
|
|
241
|
+
* there is no separate global namespace here, so it carries no meaning.
|
|
240
242
|
*
|
|
241
243
|
* @example
|
|
242
244
|
* ```typescript
|
|
243
245
|
* const reverse = useReverse(blogRoutes);
|
|
244
|
-
* reverse("
|
|
245
|
-
* reverse(".
|
|
246
|
-
* reverse("
|
|
247
|
-
* reverse("
|
|
246
|
+
* reverse("index"); // ✓ no params (dot optional)
|
|
247
|
+
* reverse(".index"); // ✓ identical to the above
|
|
248
|
+
* reverse("post", { postId: "hello" }); // ✓ with params
|
|
249
|
+
* reverse("search", {}, { q: "hi" }); // ✓ with search schema
|
|
250
|
+
* reverse("typo"); // ✗ compile error
|
|
248
251
|
* ```
|
|
249
252
|
*/
|
|
250
253
|
export type LocalReverseFunction<TLocalRoutes> = {
|
|
251
254
|
/**
|
|
252
|
-
*
|
|
255
|
+
* Route without params (leading dot optional)
|
|
253
256
|
*/
|
|
254
257
|
<TName extends keyof TLocalRoutes & string>(
|
|
255
258
|
name: IsEmptyObject<
|
|
256
259
|
ExtractParams<RoutePatternFor<TLocalRoutes, TName>>
|
|
257
260
|
> extends true
|
|
258
|
-
? `.${TName}`
|
|
261
|
+
? TName | `.${TName}`
|
|
259
262
|
: never,
|
|
260
263
|
): string;
|
|
261
264
|
|
|
262
265
|
/**
|
|
263
|
-
*
|
|
266
|
+
* Route with params (leading dot optional)
|
|
264
267
|
*/
|
|
265
268
|
<TName extends keyof TLocalRoutes & string>(
|
|
266
|
-
name: `.${TName}`,
|
|
269
|
+
name: TName | `.${TName}`,
|
|
267
270
|
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
268
271
|
): string;
|
|
269
272
|
|
|
270
273
|
/**
|
|
271
|
-
*
|
|
274
|
+
* Route with params and search (leading dot optional)
|
|
272
275
|
*/
|
|
273
276
|
<TName extends keyof TLocalRoutes & string>(
|
|
274
|
-
name: `.${TName}`,
|
|
277
|
+
name: TName | `.${TName}`,
|
|
275
278
|
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
276
279
|
search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
|
|
277
280
|
): string;
|
|
@@ -866,8 +866,11 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
|
|
|
866
866
|
};
|
|
867
867
|
|
|
868
868
|
/**
|
|
869
|
-
* Transition helper -
|
|
870
|
-
*
|
|
869
|
+
* Transition helper - opts the entry (or a wrapped group of routes) into
|
|
870
|
+
* transition-driven navigation by attaching a TransitionConfig. This drives the
|
|
871
|
+
* commit through startTransition (content hold on all React versions) and, on
|
|
872
|
+
* experimental React, places a `<ViewTransition>` boundary unless
|
|
873
|
+
* `viewTransition: false`. See skills/view-transitions for the matrix.
|
|
871
874
|
*/
|
|
872
875
|
const transition = (
|
|
873
876
|
configOrChildren?: TransitionConfig | (() => UseItems<AllUseItems>),
|
|
@@ -449,10 +449,30 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
449
449
|
): CacheItem;
|
|
450
450
|
};
|
|
451
451
|
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
452
|
+
* Opt a route (or group of routes) into transition-driven navigation.
|
|
453
|
+
*
|
|
454
|
+
* `transition()` does two independent things, and you choose how far to go:
|
|
455
|
+
* 1. startTransition (ALL React versions): the navigation commit is driven
|
|
456
|
+
* through React's startTransition, so a same-route nav (same route,
|
|
457
|
+
* different params, e.g. /product/1 -> /product/2) holds the previous
|
|
458
|
+
* content while the new loader resolves instead of flashing the route's
|
|
459
|
+
* loading() skeleton (see segment-system.tsx inTransitionScope). This is
|
|
460
|
+
* also the precondition for any view-transition animation.
|
|
461
|
+
* 2. <ViewTransition> (experimental React only): the segment content is also
|
|
462
|
+
* wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
|
|
463
|
+
* Layered on by default; pass { viewTransition: false } to keep #1 without
|
|
464
|
+
* the router boundary (and place your own <ViewTransition> instead).
|
|
465
|
+
*
|
|
466
|
+
* A view transition cannot fire without a startTransition, so the meaningful
|
|
467
|
+
* choices are (see skills/view-transitions for the full matrix):
|
|
468
|
+
* - no transition() -> neither (remount + skeleton)
|
|
469
|
+
* - transition({ viewTransition: false }) -> startTransition only (hold)
|
|
470
|
+
* - transition({}) / transition({ enter… }) -> startTransition + ViewTransition
|
|
471
|
+
*
|
|
472
|
+
* Precedence: a bare transition({}) inherits createRouter({ viewTransition })
|
|
473
|
+
* (default "auto"); an explicit per-route `viewTransition` always wins. So
|
|
474
|
+
* transition({}) is startTransition + ViewTransition under the default and
|
|
475
|
+
* startTransition only when the router sets viewTransition: false.
|
|
456
476
|
*
|
|
457
477
|
* ```typescript
|
|
458
478
|
* // Attach to a single route
|
|
@@ -466,13 +486,14 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
466
486
|
* path("/about", AboutPage),
|
|
467
487
|
* ])
|
|
468
488
|
*
|
|
469
|
-
* //
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* })
|
|
489
|
+
* // Hold content + drive view transitions, but place no router boundary:
|
|
490
|
+
* path("/product/:id", ProductPage, { name: "product" }, () => [
|
|
491
|
+
* transition({ viewTransition: false }),
|
|
492
|
+
* ])
|
|
474
493
|
* ```
|
|
475
|
-
* @param config - ViewTransition configuration (enter, exit, update, share,
|
|
494
|
+
* @param config - ViewTransition configuration (enter, exit, update, share,
|
|
495
|
+
* default, name) plus `viewTransition: "auto" | false` to toggle the router
|
|
496
|
+
* boundary (createRouter({ viewTransition }) sets the app-wide default)
|
|
476
497
|
* @param children - Optional callback returning child routes to wrap
|
|
477
498
|
*/
|
|
478
499
|
transition: {
|
|
@@ -24,7 +24,10 @@ import { isHandle, collectHandleData, type Handle } from "../handle.js";
|
|
|
24
24
|
import { buildHandleSnapshot } from "../server/handle-store.js";
|
|
25
25
|
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
26
26
|
import { _getRequestContext } from "../server/request-context.js";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
isInsideLoaderScope,
|
|
29
|
+
runInsideLoaderBodyScope,
|
|
30
|
+
} from "../server/context.js";
|
|
28
31
|
import { debugLog } from "./logging.js";
|
|
29
32
|
|
|
30
33
|
/**
|
|
@@ -353,8 +356,19 @@ function createLoaderExecutor<TEnv>(
|
|
|
353
356
|
};
|
|
354
357
|
|
|
355
358
|
const doneLoader = track(`loader:${loader.$$id}`, 2);
|
|
359
|
+
// Run the loader body inside loader scope so request-scoped reads
|
|
360
|
+
// (cookies()/headers() and non-cacheable ctx.get) are exempt from the
|
|
361
|
+
// cache-purity guards: loaders always run fresh, so their reads never leak
|
|
362
|
+
// into a cached segment. DSL loaders are already wrapped by fresh.ts; this
|
|
363
|
+
// also covers handler-invoked loaders (ctx.use(Loader) from a handler),
|
|
364
|
+
// which otherwise execute in the caller's cache scope and would wrongly
|
|
365
|
+
// throw. rendered() gating uses the captured isDslLoader (above), so this
|
|
366
|
+
// does not grant rendered() to handler-invoked loaders. Uses a body-only
|
|
367
|
+
// scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
|
|
356
368
|
const promise = Promise.resolve(
|
|
357
|
-
|
|
369
|
+
runInsideLoaderBodyScope(() =>
|
|
370
|
+
loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
371
|
+
),
|
|
358
372
|
).finally(() => {
|
|
359
373
|
pendingLoaders.delete(loader.$$id);
|
|
360
374
|
doneLoader();
|