pastoria 1.0.15 → 1.2.0

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.
@@ -5,7 +5,7 @@ import {
5
5
  RouterOps,
6
6
  } from 'pastoria-runtime';
7
7
  import {createRouter} from 'radix3';
8
- import {
8
+ import React, {
9
9
  AnchorHTMLAttributes,
10
10
  createContext,
11
11
  PropsWithChildren,
@@ -16,6 +16,7 @@ import {
16
16
  useEffect,
17
17
  useMemo,
18
18
  useState,
19
+ useTransition,
19
20
  } from 'react';
20
21
  import {preinit, preloadModule} from 'react-dom';
21
22
  import {
@@ -25,12 +26,17 @@ import {
25
26
  RelayEnvironmentProvider,
26
27
  useEntryPointLoader,
27
28
  } from 'react-relay/hooks';
29
+ import {PreloadableQueryRegistry} from 'relay-runtime';
28
30
  import * as z from 'zod/v4-mini';
29
31
 
30
32
  type RouterConf = typeof ROUTER_CONF;
33
+ type AnyRouteParams = z.infer<RouterConf[keyof RouterConf]['schema']>;
34
+ type AnyRouteEntryPoint = RouterConf[keyof RouterConf]['entrypoint'];
35
+ type LoadEntryPointFn = (params: {params: AnyRouteParams}) => void;
36
+
31
37
  const ROUTER_CONF = {
32
38
  noop: {
33
- entrypoint: null! as EntryPoint<any>,
39
+ entrypoint: null! as EntryPoint<any, any>,
34
40
  schema: z.object({}),
35
41
  },
36
42
  } as const;
@@ -39,12 +45,77 @@ export type RouteId = keyof RouterConf;
39
45
  export type NavigationDirection = string | URL | ((nextUrl: URL) => void);
40
46
 
41
47
  export interface EntryPointParams<R extends RouteId> {
42
- params: Record<string, any>;
43
- schema: RouterConf[R]['schema'];
48
+ params: z.infer<RouterConf[R]['schema']>;
49
+ queries: QueryHelpersForRoute<R>;
50
+ entryPoints: EntryPointHelpersForRoute<R>;
44
51
  }
45
52
 
53
+ /**
54
+ * Load a route entry point with proper typing.
55
+ *
56
+ * This wrapper exists because TypeScript struggles with union type inference
57
+ * when calling loadEntryPoint with a union of entry point types. All route
58
+ * entry points accept {params: Record<string, unknown>}, so this is safe.
59
+ */
60
+ function loadRouteEntryPoint(
61
+ provider: EnvironmentProvider,
62
+ entrypoint: AnyRouteEntryPoint,
63
+ params: {params: AnyRouteParams},
64
+ ) {
65
+ // Cast needed because Relay's loadEntryPoint infers params from the entry point type.
66
+ // When entrypoint is a union, the inferred params become an intersection (contravariance),
67
+ // which resolves to `never`. Our entry points all accept the same params shape, so this is safe.
68
+ return loadEntryPoint(
69
+ provider,
70
+ entrypoint as unknown as EntryPoint<unknown, {params: AnyRouteParams}>,
71
+ params,
72
+ );
73
+ }
74
+
75
+ // Convert bracket format [param] to colon format :param for radix3 router
76
+ function bracketToColon(path: string): string {
77
+ // Convert required params [param] to :param
78
+ // Note: optional params [[param]] are handled separately by expandOptionalRoutes
79
+ return path.replace(/\[([^\]]+)\]/g, ':$1');
80
+ }
81
+
82
+ // Expand routes with optional params into multiple routes
83
+ // e.g., /greet/[[name]] becomes ['/greet', '/greet/:name']
84
+ function expandOptionalRoutes(
85
+ routePath: string,
86
+ config: RouterConf[keyof RouterConf],
87
+ ): Array<[string, RouterConf[keyof RouterConf]]> {
88
+ // Check if route has optional params
89
+ const optionalMatch = routePath.match(/\/\[\[([^\]]+)\]\]/g);
90
+ if (!optionalMatch) {
91
+ // No optional params, just convert brackets to colons
92
+ return [[bracketToColon(routePath), config]];
93
+ }
94
+
95
+ // For routes with optional params, create two routes:
96
+ // 1. Without the optional segment (e.g., /greet)
97
+ // 2. With the optional segment as required (e.g., /greet/:name)
98
+ const withoutOptional = routePath.replace(/\/\[\[([^\]]+)\]\]/g, '');
99
+ const withOptional = routePath.replace(/\[\[([^\]]+)\]\]/g, '[$1]');
100
+
101
+ const routes: Array<[string, RouterConf[keyof RouterConf]]> = [];
102
+
103
+ // Add the route without optional param (handles /greet)
104
+ const pathWithout = bracketToColon(withoutOptional) || '/';
105
+ routes.push([pathWithout, config]);
106
+
107
+ // Add the route with optional param as required (handles /greet/:name)
108
+ routes.push([bracketToColon(withOptional), config]);
109
+
110
+ return routes;
111
+ }
112
+
113
+ // Create radix3 router with colon-format paths (radix3 uses :param syntax)
114
+ // Routes with optional params are expanded into multiple routes
46
115
  const ROUTER = createRouter<RouterConf[keyof RouterConf]>({
47
- routes: ROUTER_CONF,
116
+ routes: Object.fromEntries(
117
+ Object.entries(ROUTER_CONF).flatMap(([k, v]) => expandOptionalRoutes(k, v)),
118
+ ) as Record<string, RouterConf[keyof RouterConf]>,
48
119
  });
49
120
 
50
121
  class RouterLocation {
@@ -66,7 +137,7 @@ class RouterLocation {
66
137
  return ROUTER.lookup(this.pathname);
67
138
  }
68
139
 
69
- params() {
140
+ params(): AnyRouteParams {
70
141
  const matchedRoute = this.route();
71
142
  const params = {
72
143
  ...matchedRoute?.params,
@@ -76,7 +147,7 @@ class RouterLocation {
76
147
  if (matchedRoute?.schema) {
77
148
  return matchedRoute.schema.parse(params);
78
149
  } else {
79
- return params;
150
+ return params as AnyRouteParams;
80
151
  }
81
152
  }
82
153
 
@@ -127,6 +198,15 @@ export function router__hydrateStore(provider: EnvironmentProvider) {
127
198
  if ('__router_ops' in window) {
128
199
  const ops = (window as any).__router_ops as RouterOps;
129
200
  for (const [op, payload] of ops) {
201
+ // Register the ConcreteRequest with PreloadableQueryRegistry so that
202
+ // loadQuery can find it and check store availability instead of
203
+ // immediately fetching. This is critical for nested entry points whose
204
+ // query modules haven't been loaded yet.
205
+ const concreteRequest = op.request?.node;
206
+ const queryId = concreteRequest?.params?.id;
207
+ if (queryId && concreteRequest) {
208
+ PreloadableQueryRegistry.set(queryId, concreteRequest);
209
+ }
130
210
  env.commitPayload(op, payload);
131
211
  }
132
212
  }
@@ -142,10 +222,32 @@ export async function router__loadEntryPoint(
142
222
  if (!initialRoute) return null;
143
223
 
144
224
  await initialRoute.entrypoint?.root.load();
145
- return loadEntryPoint(provider, initialRoute.entrypoint, {
225
+ const ep = loadRouteEntryPoint(provider, initialRoute.entrypoint, {
146
226
  params: initialLocation.params(),
147
- schema: initialRoute.schema,
148
227
  });
228
+
229
+ // Recursively load all nested entry point modules.
230
+ // This ensures their queries get registered in PreloadableQueryRegistry
231
+ // before the server tries to serialize them.
232
+ await loadNestedEntryPointModules(ep);
233
+
234
+ return ep;
235
+ }
236
+
237
+ /**
238
+ * Recursively load all nested entry point modules so their queries
239
+ * get registered in PreloadableQueryRegistry.
240
+ */
241
+ async function loadNestedEntryPointModules(
242
+ entryPoint: AnyPreloadedEntryPoint | null,
243
+ ): Promise<void> {
244
+ if (!entryPoint) return;
245
+ for (const nestedEntry of Object.values(entryPoint.entryPoints ?? {})) {
246
+ if (nestedEntry.rootModuleID) {
247
+ await JSResource.fromModuleId(nestedEntry.rootModuleID as any).load();
248
+ }
249
+ await loadNestedEntryPointModules(nestedEntry);
250
+ }
149
251
  }
150
252
 
151
253
  interface RouterContextValue {
@@ -229,13 +331,11 @@ export function router__createAppFromEntryPoint(
229
331
  location.route()?.entrypoint,
230
332
  );
231
333
 
232
- useEffect(() => {
233
- const schema = location.route()?.schema;
234
- if (schema) {
235
- loadEntryPointRef({
236
- params: location.params(),
237
- schema,
238
- });
334
+ useMemo(() => {
335
+ const route = location.route();
336
+ if (route) {
337
+ // Cast needed for same reason as loadRouteEntryPoint - see that function's docs
338
+ (loadEntryPointRef as LoadEntryPointFn)({params: location.params()});
239
339
  }
240
340
  // eslint-disable-next-line react-hooks/exhaustive-deps
241
341
  }, [location]);
@@ -296,7 +396,7 @@ export function useRouteParams<R extends RouteId>(
296
396
 
297
397
  function router__createPathForRoute(
298
398
  routeId: RouteId,
299
- inputParams: Record<string, any>,
399
+ inputParams: Record<string, unknown>,
300
400
  ): string {
301
401
  const schema = ROUTER_CONF[routeId].schema;
302
402
  const params = schema.parse(inputParams);
@@ -305,19 +405,47 @@ function router__createPathForRoute(
305
405
  const searchParams = new URLSearchParams();
306
406
 
307
407
  Object.entries(params).forEach(([key, value]) => {
308
- if (value != null) {
309
- const paramPattern = `:${key}`;
310
- if (pathname.includes(paramPattern)) {
408
+ // Check for optional param pattern first: [[key]]
409
+ const optionalPattern = `[[${key}]]`;
410
+ if (pathname.includes(optionalPattern)) {
411
+ if (value != null) {
311
412
  pathname = pathname.replace(
312
- paramPattern,
413
+ optionalPattern,
313
414
  encodeURIComponent(String(value)),
314
415
  );
315
416
  } else {
316
- searchParams.set(key, String(value));
417
+ // Remove the optional segment entirely (including the preceding slash if present)
418
+ pathname = pathname.replace(`/${optionalPattern}`, '');
419
+ pathname = pathname.replace(optionalPattern, '');
317
420
  }
421
+ return;
422
+ }
423
+
424
+ // Check for required param pattern: [key]
425
+ const paramPattern = `[${key}]`;
426
+ if (pathname.includes(paramPattern)) {
427
+ if (value != null) {
428
+ pathname = pathname.replace(
429
+ paramPattern,
430
+ encodeURIComponent(String(value)),
431
+ );
432
+ }
433
+ return;
434
+ }
435
+
436
+ // Not a path param, add to search params if non-null
437
+ if (value != null) {
438
+ searchParams.set(key, String(value));
318
439
  }
319
440
  });
320
441
 
442
+ // Clean up any double slashes that might result
443
+ pathname = pathname.replace(/\/+/g, '/');
444
+ // Ensure we don't end up with empty path
445
+ if (pathname === '') {
446
+ pathname = '/';
447
+ }
448
+
321
449
  if (searchParams.size > 0) {
322
450
  return pathname + '?' + searchParams.toString();
323
451
  } else {
@@ -349,48 +477,63 @@ function router__evaluateNavigationDirection(nav: NavigationDirection) {
349
477
 
350
478
  export function useNavigation() {
351
479
  const {setLocation} = useContext(RouterContext);
480
+ const [isPending, startTransition] = useTransition();
352
481
 
353
482
  return useMemo(() => {
354
483
  function push(nav: NavigationDirection) {
355
- setLocation(
356
- RouterLocation.parse(router__evaluateNavigationDirection(nav), 'push'),
357
- );
484
+ startTransition(() => {
485
+ setLocation(
486
+ RouterLocation.parse(
487
+ router__evaluateNavigationDirection(nav),
488
+ 'push',
489
+ ),
490
+ );
491
+ });
358
492
  }
359
493
 
360
494
  function replace(nav: NavigationDirection) {
361
- setLocation(
362
- RouterLocation.parse(
363
- router__evaluateNavigationDirection(nav),
364
- 'replace',
365
- ),
366
- );
495
+ startTransition(() => {
496
+ setLocation(
497
+ RouterLocation.parse(
498
+ router__evaluateNavigationDirection(nav),
499
+ 'replace',
500
+ ),
501
+ );
502
+ });
367
503
  }
368
504
 
369
505
  function pushRoute<R extends RouteId>(
370
506
  routeId: R,
371
507
  params: z.input<RouterConf[R]['schema']>,
372
508
  ) {
373
- setLocation(
374
- RouterLocation.parse(
375
- router__createPathForRoute(routeId, params),
376
- 'push',
377
- ),
378
- );
509
+ startTransition(() => {
510
+ setLocation(
511
+ RouterLocation.parse(
512
+ router__createPathForRoute(routeId, params),
513
+ 'push',
514
+ ),
515
+ );
516
+ });
379
517
  }
380
518
 
381
519
  function replaceRoute<R extends RouteId>(
382
520
  routeId: R,
383
521
  params: z.input<RouterConf[R]['schema']>,
384
522
  ) {
385
- setLocation((prevLoc) =>
386
- RouterLocation.parse(
387
- router__createPathForRoute(routeId, {...prevLoc.params(), ...params}),
388
- 'replace',
389
- ),
390
- );
523
+ startTransition(() => {
524
+ setLocation((prevLoc) =>
525
+ RouterLocation.parse(
526
+ router__createPathForRoute(routeId, {
527
+ ...prevLoc.params(),
528
+ ...params,
529
+ }),
530
+ 'replace',
531
+ ),
532
+ );
533
+ });
391
534
  }
392
535
 
393
- return {push, replace, pushRoute, replaceRoute} as const;
536
+ return {push, replace, pushRoute, replaceRoute, isPending} as const;
394
537
  }, [setLocation]);
395
538
  }
396
539
 
package/CHANGELOG.md DELETED
@@ -1,98 +0,0 @@
1
- # pastoria
2
-
3
- ## 1.0.15
4
-
5
- ### Patch Changes
6
-
7
- - 96bcdfd: Always run relay-compiler with repersist
8
-
9
- ## 1.0.14
10
-
11
- ### Patch Changes
12
-
13
- - a38076a: Trim down variables passed to entrypoints to only those needed
14
-
15
- ## 1.0.13
16
-
17
- ### Patch Changes
18
-
19
- - 0ef0bdc: Automatically infer variable names and types from queries on
20
- resource-routes if none provided
21
-
22
- ## 1.0.12
23
-
24
- ### Patch Changes
25
-
26
- - 641b356: Add pastoria make command
27
-
28
- ## 1.0.11
29
-
30
- ### Patch Changes
31
-
32
- - b139f3c: Bump patch versions
33
- - Updated dependencies [b139f3c]
34
- - pastoria-config@1.0.1
35
-
36
- ## 1.0.10
37
-
38
- ### Patch Changes
39
-
40
- - 1ae49d3: Update router generator to automatically create an entrypoint and
41
- resource
42
- - 4ed4588: Don't duplicate JSResource import when generating code
43
- - e7e5519: Detect queries and entrypoints from type annotations
44
-
45
- ## 1.0.9
46
-
47
- ### Patch Changes
48
-
49
- - 60ab948: Add support for routing shorthands
50
- - 37afd6c: Add support for @serverRoute
51
-
52
- ## 1.0.8
53
-
54
- ### Patch Changes
55
-
56
- - 265903c: Add babel-plugin-relay dependency
57
-
58
- ## 1.0.7
59
-
60
- ### Patch Changes
61
-
62
- - Move generated persisted queries output
63
-
64
- ## 1.0.6
65
-
66
- ### Patch Changes
67
-
68
- - d81851d: Added a custom GraphQL server to remove the Yoga dependency
69
-
70
- ## 1.0.5
71
-
72
- ### Patch Changes
73
-
74
- - Add pastoria-runtime to `noExternal`
75
-
76
- ## 1.0.4
77
-
78
- ### Patch Changes
79
-
80
- - Update tsconfig to target nodenext modules
81
-
82
- ## 1.0.3
83
-
84
- ### Patch Changes
85
-
86
- - Fix package.json bin config
87
-
88
- ## 1.0.2
89
-
90
- ### Patch Changes
91
-
92
- - Remove usage of experimentalStripTypes
93
-
94
- ## 1.0.1
95
-
96
- ### Patch Changes
97
-
98
- - Pastoria alpha version for testing