@matthesketh/utopia-router 0.4.0 → 0.5.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.
package/dist/index.cjs CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  isNavigating: () => isNavigating,
37
37
  matchRoute: () => matchRoute,
38
38
  navigate: () => navigate,
39
+ preloadRoute: () => preloadRoute,
39
40
  queryParams: () => queryParams,
40
41
  setQueryParam: () => setQueryParam,
41
42
  setQueryParams: () => setQueryParams
@@ -43,26 +44,37 @@ __export(index_exports, {
43
44
  module.exports = __toCommonJS(index_exports);
44
45
 
45
46
  // src/matcher.ts
47
+ var BACKSLASH_RE = /\\/g;
48
+ var PAGE_FILE_RE = /\/?\+page\.\w+$/;
49
+ var LAYOUT_OR_ERROR_FILE_RE = /\/?\+(layout|error)\.\w+$/;
50
+ var GROUP_SEGMENT_RE = /^\(.+\)$/;
51
+ var REST_PARAM_RE = /^\[\.\.\..+\]$/;
52
+ var DYNAMIC_PARAM_RE = /^\[.+\]$/;
53
+ var ROOT_ROUTE_RE = /^\/$/;
54
+ var REGEX_SPECIAL_CHARS_RE = /[.*+?^${}()|[\]\\]/g;
55
+ var PAGE_FILE_TEST_RE = /\+page\.\w+$/;
56
+ var LAYOUT_FILE_TEST_RE = /\+layout\.\w+$/;
57
+ var ERROR_FILE_TEST_RE = /\+error\.\w+$/;
46
58
  function filePathToRoute(filePath) {
47
- let normalized = filePath.replace(/\\/g, "/");
59
+ let normalized = filePath.replace(BACKSLASH_RE, "/");
48
60
  const routesIdx = normalized.indexOf("routes/");
49
61
  if (routesIdx !== -1) {
50
62
  normalized = normalized.slice(routesIdx + "routes/".length);
51
63
  }
52
- normalized = normalized.replace(/\/?\+page\.\w+$/, "");
53
- normalized = normalized.replace(/\/?\+(layout|error)\.\w+$/, "");
64
+ normalized = normalized.replace(PAGE_FILE_RE, "");
65
+ normalized = normalized.replace(LAYOUT_OR_ERROR_FILE_RE, "");
54
66
  const segments = normalized.split("/").filter(Boolean);
55
67
  const routeSegments = [];
56
68
  for (const segment of segments) {
57
- if (/^\(.+\)$/.test(segment)) {
69
+ if (GROUP_SEGMENT_RE.test(segment)) {
58
70
  continue;
59
71
  }
60
- if (/^\[\.\.\..+\]$/.test(segment)) {
72
+ if (REST_PARAM_RE.test(segment)) {
61
73
  const paramName = segment.slice(4, -1);
62
74
  routeSegments.push(`*${paramName}`);
63
75
  continue;
64
76
  }
65
- if (/^\[.+\]$/.test(segment)) {
77
+ if (DYNAMIC_PARAM_RE.test(segment)) {
66
78
  const paramName = segment.slice(1, -1);
67
79
  routeSegments.push(`:${paramName}`);
68
80
  continue;
@@ -76,7 +88,7 @@ function compilePattern(pattern) {
76
88
  const params = [];
77
89
  if (pattern === "/") {
78
90
  return {
79
- regex: /^\/$/,
91
+ regex: ROOT_ROUTE_RE,
80
92
  params: []
81
93
  };
82
94
  }
@@ -98,12 +110,13 @@ function compilePattern(pattern) {
98
110
  }
99
111
  regexStr = "^" + regexStr + "/?$";
100
112
  return {
113
+ // Dynamic regex — built from sanitized input (segments are escaped via escapeRegex).
101
114
  regex: new RegExp(regexStr),
102
115
  params
103
116
  };
104
117
  }
105
118
  function escapeRegex(str) {
106
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ return str.replace(REGEX_SPECIAL_CHARS_RE, "\\$&");
107
120
  }
108
121
  function matchRoute(url, routes2) {
109
122
  let pathname = url.pathname;
@@ -127,12 +140,12 @@ function buildRouteTable(manifest) {
127
140
  const layouts = /* @__PURE__ */ new Map();
128
141
  const errors = /* @__PURE__ */ new Map();
129
142
  for (const [filePath, importFn] of Object.entries(manifest)) {
130
- const normalized = filePath.replace(/\\/g, "/");
131
- if (/\+page\.\w+$/.test(normalized)) {
143
+ const normalized = filePath.replace(BACKSLASH_RE, "/");
144
+ if (PAGE_FILE_TEST_RE.test(normalized)) {
132
145
  pages.set(normalized, importFn);
133
- } else if (/\+layout\.\w+$/.test(normalized)) {
146
+ } else if (LAYOUT_FILE_TEST_RE.test(normalized)) {
134
147
  layouts.set(normalized, importFn);
135
- } else if (/\+error\.\w+$/.test(normalized)) {
148
+ } else if (ERROR_FILE_TEST_RE.test(normalized)) {
136
149
  errors.set(normalized, importFn);
137
150
  }
138
151
  }
@@ -179,12 +192,12 @@ function routeSpecificity(path) {
179
192
  return score;
180
193
  }
181
194
  function findNearestSpecialFile(pageFilePath, specialFiles) {
182
- const normalized = pageFilePath.replace(/\\/g, "/");
195
+ const normalized = pageFilePath.replace(BACKSLASH_RE, "/");
183
196
  const lastSlash = normalized.lastIndexOf("/");
184
197
  let dir = lastSlash !== -1 ? normalized.slice(0, lastSlash) : "";
185
198
  while (dir) {
186
199
  for (const [specialPath, importFn] of specialFiles) {
187
- const specialNorm = specialPath.replace(/\\/g, "/");
200
+ const specialNorm = specialPath.replace(BACKSLASH_RE, "/");
188
201
  const specialLastSlash = specialNorm.lastIndexOf("/");
189
202
  const specialDir = specialLastSlash !== -1 ? specialNorm.slice(0, specialLastSlash) : "";
190
203
  if (specialDir === dir) {
@@ -202,6 +215,7 @@ function findNearestSpecialFile(pageFilePath, specialFiles) {
202
215
 
203
216
  // src/router.ts
204
217
  var import_utopia_core = require("@matthesketh/utopia-core");
218
+ var VALID_DOM_ID_RE = /^[A-Za-z0-9_-]+$/;
205
219
  var currentRoute = (0, import_utopia_core.signal)(null);
206
220
  var isNavigating = (0, import_utopia_core.signal)(false);
207
221
  var routes = [];
@@ -324,10 +338,13 @@ async function navigate(url, options = {}) {
324
338
  currentRoute.set(match);
325
339
  requestAnimationFrame(() => {
326
340
  if (fullUrl.hash) {
327
- const el = document.getElementById(fullUrl.hash.slice(1));
328
- if (el) {
329
- el.scrollIntoView();
330
- return;
341
+ const hashId = fullUrl.hash.slice(1);
342
+ if (hashId && VALID_DOM_ID_RE.test(hashId)) {
343
+ const el = document.getElementById(hashId);
344
+ if (el) {
345
+ el.scrollIntoView();
346
+ return;
347
+ }
331
348
  }
332
349
  }
333
350
  window.scrollTo(0, 0);
@@ -401,12 +418,35 @@ function capScrollPositions() {
401
418
 
402
419
  // src/components.ts
403
420
  var import_utopia_core2 = require("@matthesketh/utopia-core");
421
+ var moduleCache = /* @__PURE__ */ new Map();
422
+ async function preloadRoute() {
423
+ const match = currentRoute.peek();
424
+ if (!match) return;
425
+ const promises = [match.route.component()];
426
+ if (match.route.layout) {
427
+ promises.push(match.route.layout());
428
+ }
429
+ const modules = await Promise.all(promises);
430
+ moduleCache.set(match.route.component, modules[0]);
431
+ if (match.route.layout && modules.length > 1) {
432
+ moduleCache.set(match.route.layout, modules[1]);
433
+ }
434
+ }
404
435
  function createRouterView() {
405
436
  const container = document.createElement("div");
406
437
  container.setAttribute("data-utopia-router-view", "");
407
438
  let currentCleanup = null;
408
439
  let currentMatch = null;
409
440
  let loadId = 0;
441
+ const initialMatch = currentRoute.peek();
442
+ if (initialMatch) {
443
+ const syncResult = tryRenderFromCache(initialMatch);
444
+ if (syncResult) {
445
+ container.appendChild(syncResult.node);
446
+ currentCleanup = syncResult.cleanup;
447
+ currentMatch = initialMatch;
448
+ }
449
+ }
410
450
  (0, import_utopia_core2.effect)(() => {
411
451
  const match = currentRoute();
412
452
  if (match === currentMatch) {
@@ -440,15 +480,58 @@ function createRouterView() {
440
480
  });
441
481
  return container;
442
482
  }
483
+ function tryRenderFromCache(match) {
484
+ const cachedPage = moduleCache.get(match.route.component);
485
+ if (!cachedPage) return null;
486
+ if (match.route.layout && !moduleCache.has(match.route.layout)) return null;
487
+ const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : null;
488
+ moduleCache.delete(match.route.component);
489
+ if (match.route.layout) moduleCache.delete(match.route.layout);
490
+ const PageComponent = cachedPage.default ?? cachedPage;
491
+ const LayoutComponent = cachedLayout ? cachedLayout.default ?? cachedLayout : null;
492
+ const pageNode = renderComponent(PageComponent, {
493
+ params: match.params,
494
+ url: match.url
495
+ });
496
+ let node;
497
+ if (LayoutComponent) {
498
+ node = renderComponent(LayoutComponent, {
499
+ params: match.params,
500
+ url: match.url,
501
+ children: pageNode
502
+ });
503
+ } else {
504
+ node = pageNode;
505
+ }
506
+ return {
507
+ node,
508
+ cleanup: () => {
509
+ if (node.parentNode) {
510
+ node.parentNode.removeChild(node);
511
+ }
512
+ }
513
+ };
514
+ }
443
515
  async function loadRouteComponent(match) {
444
516
  try {
445
- const promises = [match.route.component()];
446
- if (match.route.layout) {
447
- promises.push(match.route.layout());
517
+ const cachedPage = moduleCache.get(match.route.component);
518
+ const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : void 0;
519
+ let pageModule;
520
+ let layoutModule = null;
521
+ if (cachedPage) {
522
+ pageModule = cachedPage;
523
+ layoutModule = cachedLayout ?? null;
524
+ moduleCache.delete(match.route.component);
525
+ if (match.route.layout) moduleCache.delete(match.route.layout);
526
+ } else {
527
+ const promises = [match.route.component()];
528
+ if (match.route.layout) {
529
+ promises.push(match.route.layout());
530
+ }
531
+ const modules = await Promise.all(promises);
532
+ pageModule = modules[0];
533
+ layoutModule = modules.length > 1 ? modules[1] : null;
448
534
  }
449
- const modules = await Promise.all(promises);
450
- const pageModule = modules[0];
451
- const layoutModule = modules.length > 1 ? modules[1] : null;
452
535
  if (currentRoute.peek() !== match) {
453
536
  return null;
454
537
  }
@@ -572,8 +655,13 @@ function clearContainer(container) {
572
655
  container.removeChild(container.firstChild);
573
656
  }
574
657
  }
658
+ var AMPERSAND_RE = /&/g;
659
+ var LESS_THAN_RE = /</g;
660
+ var GREATER_THAN_RE = />/g;
661
+ var DOUBLE_QUOTE_RE = /"/g;
662
+ var SINGLE_QUOTE_RE = /'/g;
575
663
  function escapeHtml(str) {
576
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
664
+ return str.replace(AMPERSAND_RE, "&amp;").replace(LESS_THAN_RE, "&lt;").replace(GREATER_THAN_RE, "&gt;").replace(DOUBLE_QUOTE_RE, "&quot;").replace(SINGLE_QUOTE_RE, "&#x27;");
577
665
  }
578
666
 
579
667
  // src/query.ts
@@ -641,6 +729,7 @@ function getRouteParam(name) {
641
729
  isNavigating,
642
730
  matchRoute,
643
731
  navigate,
732
+ preloadRoute,
644
733
  queryParams,
645
734
  setQueryParam,
646
735
  setQueryParams
package/dist/index.d.cts CHANGED
@@ -167,6 +167,20 @@ declare function beforeNavigate(hook: BeforeNavigateHook): () => void;
167
167
  */
168
168
  declare function destroy(): void;
169
169
 
170
+ /**
171
+ * Pre-load the current route's component (and layout) so that
172
+ * `createRouterView()` can render it synchronously on first paint.
173
+ *
174
+ * Call this **after** `createRouter()` and **before** `mount()`.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * createRouter(routes)
179
+ * await preloadRoute()
180
+ * mount(App, '#app')
181
+ * ```
182
+ */
183
+ declare function preloadRoute(): Promise<void>;
170
184
  /**
171
185
  * Creates a DOM node that renders the current route's component.
172
186
  *
@@ -239,4 +253,4 @@ declare function setQueryParams(params: Record<string, string | null>): void;
239
253
  */
240
254
  declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
241
255
 
242
- export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, queryParams, setQueryParam, setQueryParams };
256
+ export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, preloadRoute, queryParams, setQueryParam, setQueryParams };
package/dist/index.d.ts CHANGED
@@ -167,6 +167,20 @@ declare function beforeNavigate(hook: BeforeNavigateHook): () => void;
167
167
  */
168
168
  declare function destroy(): void;
169
169
 
170
+ /**
171
+ * Pre-load the current route's component (and layout) so that
172
+ * `createRouterView()` can render it synchronously on first paint.
173
+ *
174
+ * Call this **after** `createRouter()` and **before** `mount()`.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * createRouter(routes)
179
+ * await preloadRoute()
180
+ * mount(App, '#app')
181
+ * ```
182
+ */
183
+ declare function preloadRoute(): Promise<void>;
170
184
  /**
171
185
  * Creates a DOM node that renders the current route's component.
172
186
  *
@@ -239,4 +253,4 @@ declare function setQueryParams(params: Record<string, string | null>): void;
239
253
  */
240
254
  declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
241
255
 
242
- export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, queryParams, setQueryParam, setQueryParams };
256
+ export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, getQueryParam, getRouteParam, isNavigating, matchRoute, navigate, preloadRoute, queryParams, setQueryParam, setQueryParams };
package/dist/index.js CHANGED
@@ -1,24 +1,35 @@
1
1
  // src/matcher.ts
2
+ var BACKSLASH_RE = /\\/g;
3
+ var PAGE_FILE_RE = /\/?\+page\.\w+$/;
4
+ var LAYOUT_OR_ERROR_FILE_RE = /\/?\+(layout|error)\.\w+$/;
5
+ var GROUP_SEGMENT_RE = /^\(.+\)$/;
6
+ var REST_PARAM_RE = /^\[\.\.\..+\]$/;
7
+ var DYNAMIC_PARAM_RE = /^\[.+\]$/;
8
+ var ROOT_ROUTE_RE = /^\/$/;
9
+ var REGEX_SPECIAL_CHARS_RE = /[.*+?^${}()|[\]\\]/g;
10
+ var PAGE_FILE_TEST_RE = /\+page\.\w+$/;
11
+ var LAYOUT_FILE_TEST_RE = /\+layout\.\w+$/;
12
+ var ERROR_FILE_TEST_RE = /\+error\.\w+$/;
2
13
  function filePathToRoute(filePath) {
3
- let normalized = filePath.replace(/\\/g, "/");
14
+ let normalized = filePath.replace(BACKSLASH_RE, "/");
4
15
  const routesIdx = normalized.indexOf("routes/");
5
16
  if (routesIdx !== -1) {
6
17
  normalized = normalized.slice(routesIdx + "routes/".length);
7
18
  }
8
- normalized = normalized.replace(/\/?\+page\.\w+$/, "");
9
- normalized = normalized.replace(/\/?\+(layout|error)\.\w+$/, "");
19
+ normalized = normalized.replace(PAGE_FILE_RE, "");
20
+ normalized = normalized.replace(LAYOUT_OR_ERROR_FILE_RE, "");
10
21
  const segments = normalized.split("/").filter(Boolean);
11
22
  const routeSegments = [];
12
23
  for (const segment of segments) {
13
- if (/^\(.+\)$/.test(segment)) {
24
+ if (GROUP_SEGMENT_RE.test(segment)) {
14
25
  continue;
15
26
  }
16
- if (/^\[\.\.\..+\]$/.test(segment)) {
27
+ if (REST_PARAM_RE.test(segment)) {
17
28
  const paramName = segment.slice(4, -1);
18
29
  routeSegments.push(`*${paramName}`);
19
30
  continue;
20
31
  }
21
- if (/^\[.+\]$/.test(segment)) {
32
+ if (DYNAMIC_PARAM_RE.test(segment)) {
22
33
  const paramName = segment.slice(1, -1);
23
34
  routeSegments.push(`:${paramName}`);
24
35
  continue;
@@ -32,7 +43,7 @@ function compilePattern(pattern) {
32
43
  const params = [];
33
44
  if (pattern === "/") {
34
45
  return {
35
- regex: /^\/$/,
46
+ regex: ROOT_ROUTE_RE,
36
47
  params: []
37
48
  };
38
49
  }
@@ -54,12 +65,13 @@ function compilePattern(pattern) {
54
65
  }
55
66
  regexStr = "^" + regexStr + "/?$";
56
67
  return {
68
+ // Dynamic regex — built from sanitized input (segments are escaped via escapeRegex).
57
69
  regex: new RegExp(regexStr),
58
70
  params
59
71
  };
60
72
  }
61
73
  function escapeRegex(str) {
62
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
+ return str.replace(REGEX_SPECIAL_CHARS_RE, "\\$&");
63
75
  }
64
76
  function matchRoute(url, routes2) {
65
77
  let pathname = url.pathname;
@@ -83,12 +95,12 @@ function buildRouteTable(manifest) {
83
95
  const layouts = /* @__PURE__ */ new Map();
84
96
  const errors = /* @__PURE__ */ new Map();
85
97
  for (const [filePath, importFn] of Object.entries(manifest)) {
86
- const normalized = filePath.replace(/\\/g, "/");
87
- if (/\+page\.\w+$/.test(normalized)) {
98
+ const normalized = filePath.replace(BACKSLASH_RE, "/");
99
+ if (PAGE_FILE_TEST_RE.test(normalized)) {
88
100
  pages.set(normalized, importFn);
89
- } else if (/\+layout\.\w+$/.test(normalized)) {
101
+ } else if (LAYOUT_FILE_TEST_RE.test(normalized)) {
90
102
  layouts.set(normalized, importFn);
91
- } else if (/\+error\.\w+$/.test(normalized)) {
103
+ } else if (ERROR_FILE_TEST_RE.test(normalized)) {
92
104
  errors.set(normalized, importFn);
93
105
  }
94
106
  }
@@ -135,12 +147,12 @@ function routeSpecificity(path) {
135
147
  return score;
136
148
  }
137
149
  function findNearestSpecialFile(pageFilePath, specialFiles) {
138
- const normalized = pageFilePath.replace(/\\/g, "/");
150
+ const normalized = pageFilePath.replace(BACKSLASH_RE, "/");
139
151
  const lastSlash = normalized.lastIndexOf("/");
140
152
  let dir = lastSlash !== -1 ? normalized.slice(0, lastSlash) : "";
141
153
  while (dir) {
142
154
  for (const [specialPath, importFn] of specialFiles) {
143
- const specialNorm = specialPath.replace(/\\/g, "/");
155
+ const specialNorm = specialPath.replace(BACKSLASH_RE, "/");
144
156
  const specialLastSlash = specialNorm.lastIndexOf("/");
145
157
  const specialDir = specialLastSlash !== -1 ? specialNorm.slice(0, specialLastSlash) : "";
146
158
  if (specialDir === dir) {
@@ -158,6 +170,7 @@ function findNearestSpecialFile(pageFilePath, specialFiles) {
158
170
 
159
171
  // src/router.ts
160
172
  import { signal } from "@matthesketh/utopia-core";
173
+ var VALID_DOM_ID_RE = /^[A-Za-z0-9_-]+$/;
161
174
  var currentRoute = signal(null);
162
175
  var isNavigating = signal(false);
163
176
  var routes = [];
@@ -280,10 +293,13 @@ async function navigate(url, options = {}) {
280
293
  currentRoute.set(match);
281
294
  requestAnimationFrame(() => {
282
295
  if (fullUrl.hash) {
283
- const el = document.getElementById(fullUrl.hash.slice(1));
284
- if (el) {
285
- el.scrollIntoView();
286
- return;
296
+ const hashId = fullUrl.hash.slice(1);
297
+ if (hashId && VALID_DOM_ID_RE.test(hashId)) {
298
+ const el = document.getElementById(hashId);
299
+ if (el) {
300
+ el.scrollIntoView();
301
+ return;
302
+ }
287
303
  }
288
304
  }
289
305
  window.scrollTo(0, 0);
@@ -357,12 +373,35 @@ function capScrollPositions() {
357
373
 
358
374
  // src/components.ts
359
375
  import { effect } from "@matthesketh/utopia-core";
376
+ var moduleCache = /* @__PURE__ */ new Map();
377
+ async function preloadRoute() {
378
+ const match = currentRoute.peek();
379
+ if (!match) return;
380
+ const promises = [match.route.component()];
381
+ if (match.route.layout) {
382
+ promises.push(match.route.layout());
383
+ }
384
+ const modules = await Promise.all(promises);
385
+ moduleCache.set(match.route.component, modules[0]);
386
+ if (match.route.layout && modules.length > 1) {
387
+ moduleCache.set(match.route.layout, modules[1]);
388
+ }
389
+ }
360
390
  function createRouterView() {
361
391
  const container = document.createElement("div");
362
392
  container.setAttribute("data-utopia-router-view", "");
363
393
  let currentCleanup = null;
364
394
  let currentMatch = null;
365
395
  let loadId = 0;
396
+ const initialMatch = currentRoute.peek();
397
+ if (initialMatch) {
398
+ const syncResult = tryRenderFromCache(initialMatch);
399
+ if (syncResult) {
400
+ container.appendChild(syncResult.node);
401
+ currentCleanup = syncResult.cleanup;
402
+ currentMatch = initialMatch;
403
+ }
404
+ }
366
405
  effect(() => {
367
406
  const match = currentRoute();
368
407
  if (match === currentMatch) {
@@ -396,15 +435,58 @@ function createRouterView() {
396
435
  });
397
436
  return container;
398
437
  }
438
+ function tryRenderFromCache(match) {
439
+ const cachedPage = moduleCache.get(match.route.component);
440
+ if (!cachedPage) return null;
441
+ if (match.route.layout && !moduleCache.has(match.route.layout)) return null;
442
+ const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : null;
443
+ moduleCache.delete(match.route.component);
444
+ if (match.route.layout) moduleCache.delete(match.route.layout);
445
+ const PageComponent = cachedPage.default ?? cachedPage;
446
+ const LayoutComponent = cachedLayout ? cachedLayout.default ?? cachedLayout : null;
447
+ const pageNode = renderComponent(PageComponent, {
448
+ params: match.params,
449
+ url: match.url
450
+ });
451
+ let node;
452
+ if (LayoutComponent) {
453
+ node = renderComponent(LayoutComponent, {
454
+ params: match.params,
455
+ url: match.url,
456
+ children: pageNode
457
+ });
458
+ } else {
459
+ node = pageNode;
460
+ }
461
+ return {
462
+ node,
463
+ cleanup: () => {
464
+ if (node.parentNode) {
465
+ node.parentNode.removeChild(node);
466
+ }
467
+ }
468
+ };
469
+ }
399
470
  async function loadRouteComponent(match) {
400
471
  try {
401
- const promises = [match.route.component()];
402
- if (match.route.layout) {
403
- promises.push(match.route.layout());
472
+ const cachedPage = moduleCache.get(match.route.component);
473
+ const cachedLayout = match.route.layout ? moduleCache.get(match.route.layout) : void 0;
474
+ let pageModule;
475
+ let layoutModule = null;
476
+ if (cachedPage) {
477
+ pageModule = cachedPage;
478
+ layoutModule = cachedLayout ?? null;
479
+ moduleCache.delete(match.route.component);
480
+ if (match.route.layout) moduleCache.delete(match.route.layout);
481
+ } else {
482
+ const promises = [match.route.component()];
483
+ if (match.route.layout) {
484
+ promises.push(match.route.layout());
485
+ }
486
+ const modules = await Promise.all(promises);
487
+ pageModule = modules[0];
488
+ layoutModule = modules.length > 1 ? modules[1] : null;
404
489
  }
405
- const modules = await Promise.all(promises);
406
- const pageModule = modules[0];
407
- const layoutModule = modules.length > 1 ? modules[1] : null;
408
490
  if (currentRoute.peek() !== match) {
409
491
  return null;
410
492
  }
@@ -528,8 +610,13 @@ function clearContainer(container) {
528
610
  container.removeChild(container.firstChild);
529
611
  }
530
612
  }
613
+ var AMPERSAND_RE = /&/g;
614
+ var LESS_THAN_RE = /</g;
615
+ var GREATER_THAN_RE = />/g;
616
+ var DOUBLE_QUOTE_RE = /"/g;
617
+ var SINGLE_QUOTE_RE = /'/g;
531
618
  function escapeHtml(str) {
532
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
619
+ return str.replace(AMPERSAND_RE, "&amp;").replace(LESS_THAN_RE, "&lt;").replace(GREATER_THAN_RE, "&gt;").replace(DOUBLE_QUOTE_RE, "&quot;").replace(SINGLE_QUOTE_RE, "&#x27;");
533
620
  }
534
621
 
535
622
  // src/query.ts
@@ -596,6 +683,7 @@ export {
596
683
  isNavigating,
597
684
  matchRoute,
598
685
  navigate,
686
+ preloadRoute,
599
687
  queryParams,
600
688
  setQueryParam,
601
689
  setQueryParams
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/utopia-router",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "File-based routing for UtopiaJS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,12 +39,12 @@
39
39
  "files": [
40
40
  "dist"
41
41
  ],
42
- "dependencies": {
43
- "@matthesketh/utopia-core": "0.4.0",
44
- "@matthesketh/utopia-runtime": "0.4.0"
45
- },
46
42
  "scripts": {
47
43
  "build": "tsup src/index.ts --format esm,cjs --dts",
48
44
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
45
+ },
46
+ "dependencies": {
47
+ "@matthesketh/utopia-core": "workspace:*",
48
+ "@matthesketh/utopia-runtime": "workspace:*"
49
49
  }
50
- }
50
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Matt Hesketh
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.