pastoria 1.1.0 → 1.2.1

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,49 +477,64 @@ 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;
394
- }, [setLocation]);
536
+ return {push, replace, pushRoute, replaceRoute, isPending} as const;
537
+ }, [setLocation, isPending]);
395
538
  }
396
539
 
397
540
  export function Link({
package/justfile DELETED
@@ -1,41 +0,0 @@
1
- # Generate schema, relay, and router code.
2
- [no-cd]
3
- default: router
4
-
5
- # Generate GraphQL schema using Grats
6
- [no-cd]
7
- [private]
8
- grats:
9
- pnpm exec grats
10
-
11
- # Compile Relay queries with persisted queries
12
- [no-cd]
13
- [private]
14
- relay: grats
15
- pnpm exec relay-compiler --repersist
16
-
17
- # Generate Pastoria router artifacts (app_root, context, router, resources, handlers)
18
- [no-cd]
19
- [private]
20
- router: relay
21
- pnpm exec pastoria gen
22
-
23
- # Build client bundle
24
- [no-cd]
25
- _client: router
26
- pnpm exec pastoria build client
27
-
28
- # Build server bundle
29
- [no-cd]
30
- _server: router
31
- pnpm exec pastoria build server
32
-
33
- # Build both client and server for production
34
- [no-cd]
35
- [parallel]
36
- release: _client _server
37
-
38
- # Start development server
39
- [no-cd]
40
- dev:
41
- pnpm exec pastoria dev