@matthesketh/utopia-router 0.3.0 → 0.4.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.
package/dist/index.cjs CHANGED
@@ -31,9 +31,14 @@ __export(index_exports, {
31
31
  destroy: () => destroy,
32
32
  filePathToRoute: () => filePathToRoute,
33
33
  forward: () => forward,
34
+ getQueryParam: () => getQueryParam,
35
+ getRouteParam: () => getRouteParam,
34
36
  isNavigating: () => isNavigating,
35
37
  matchRoute: () => matchRoute,
36
- navigate: () => navigate
38
+ navigate: () => navigate,
39
+ queryParams: () => queryParams,
40
+ setQueryParam: () => setQueryParam,
41
+ setQueryParams: () => setQueryParams
37
42
  });
38
43
  module.exports = __toCommonJS(index_exports);
39
44
 
@@ -401,33 +406,41 @@ function createRouterView() {
401
406
  container.setAttribute("data-utopia-router-view", "");
402
407
  let currentCleanup = null;
403
408
  let currentMatch = null;
409
+ let loadId = 0;
404
410
  (0, import_utopia_core2.effect)(() => {
405
411
  const match = currentRoute();
406
412
  if (match === currentMatch) {
407
413
  return;
408
414
  }
409
415
  currentMatch = match;
410
- if (currentCleanup) {
411
- currentCleanup();
412
- currentCleanup = null;
413
- }
414
- while (container.firstChild) {
415
- container.removeChild(container.firstChild);
416
- }
416
+ const thisLoadId = ++loadId;
417
417
  if (!match) {
418
+ if (currentCleanup) {
419
+ currentCleanup();
420
+ currentCleanup = null;
421
+ }
422
+ clearContainer(container);
418
423
  const notFound = document.createElement("div");
419
424
  notFound.setAttribute("data-utopia-not-found", "");
420
425
  notFound.textContent = "Page not found";
421
426
  container.appendChild(notFound);
422
427
  return;
423
428
  }
424
- loadRouteComponent(match, container).then((cleanupFn) => {
425
- currentCleanup = cleanupFn;
429
+ loadRouteComponent(match).then((result) => {
430
+ if (thisLoadId !== loadId) return;
431
+ if (!result) return;
432
+ if (currentCleanup) {
433
+ currentCleanup();
434
+ currentCleanup = null;
435
+ }
436
+ clearContainer(container);
437
+ container.appendChild(result.node);
438
+ currentCleanup = result.cleanup;
426
439
  });
427
440
  });
428
441
  return container;
429
442
  }
430
- async function loadRouteComponent(match, container) {
443
+ async function loadRouteComponent(match) {
431
444
  try {
432
445
  const promises = [match.route.component()];
433
446
  if (match.route.layout) {
@@ -441,26 +454,26 @@ async function loadRouteComponent(match, container) {
441
454
  }
442
455
  const PageComponent = pageModule.default ?? pageModule;
443
456
  const LayoutComponent = layoutModule ? layoutModule.default ?? layoutModule : null;
444
- while (container.firstChild) {
445
- container.removeChild(container.firstChild);
446
- }
447
457
  const pageNode = renderComponent(PageComponent, {
448
458
  params: match.params,
449
459
  url: match.url
450
460
  });
461
+ let node;
451
462
  if (LayoutComponent) {
452
- const layoutNode = renderComponent(LayoutComponent, {
463
+ node = renderComponent(LayoutComponent, {
453
464
  params: match.params,
454
465
  url: match.url,
455
466
  children: pageNode
456
467
  });
457
- container.appendChild(layoutNode);
458
468
  } else {
459
- container.appendChild(pageNode);
469
+ node = pageNode;
460
470
  }
461
- return () => {
462
- while (container.firstChild) {
463
- container.removeChild(container.firstChild);
471
+ return {
472
+ node,
473
+ cleanup: () => {
474
+ if (node.parentNode) {
475
+ node.parentNode.removeChild(node);
476
+ }
464
477
  }
465
478
  };
466
479
  } catch (err) {
@@ -468,26 +481,33 @@ async function loadRouteComponent(match, container) {
468
481
  try {
469
482
  const errorModule = await match.route.error();
470
483
  const ErrorComponent = errorModule.default ?? errorModule;
471
- while (container.firstChild) {
472
- container.removeChild(container.firstChild);
473
- }
474
484
  const errorNode = renderComponent(ErrorComponent, {
475
485
  error: err,
476
486
  params: match.params,
477
487
  url: match.url
478
488
  });
479
- container.appendChild(errorNode);
489
+ return {
490
+ node: errorNode,
491
+ cleanup: () => {
492
+ if (errorNode.parentNode) {
493
+ errorNode.parentNode.removeChild(errorNode);
494
+ }
495
+ }
496
+ };
480
497
  } catch {
481
- renderFallbackError(container, err);
498
+ return {
499
+ node: createFallbackErrorNode(err),
500
+ cleanup: () => {
501
+ }
502
+ };
482
503
  }
483
504
  } else {
484
- renderFallbackError(container, err);
505
+ return {
506
+ node: createFallbackErrorNode(err),
507
+ cleanup: () => {
508
+ }
509
+ };
485
510
  }
486
- return () => {
487
- while (container.firstChild) {
488
- container.removeChild(container.firstChild);
489
- }
490
- };
491
511
  }
492
512
  }
493
513
  function renderComponent(component, props) {
@@ -510,10 +530,7 @@ function renderComponent(component, props) {
510
530
  div.textContent = "[Component render error]";
511
531
  return div;
512
532
  }
513
- function renderFallbackError(container, error) {
514
- while (container.firstChild) {
515
- container.removeChild(container.firstChild);
516
- }
533
+ function createFallbackErrorNode(error) {
517
534
  const errorDiv = document.createElement("div");
518
535
  errorDiv.setAttribute("data-utopia-error", "");
519
536
  errorDiv.style.cssText = "padding:2rem;color:#dc2626;font-family:monospace;";
@@ -523,7 +540,7 @@ function renderFallbackError(container, error) {
523
540
  error instanceof Error ? error.message : String(error)
524
541
  )}</pre>
525
542
  `;
526
- container.appendChild(errorDiv);
543
+ return errorDiv;
527
544
  }
528
545
  function createLink(props) {
529
546
  const anchor = document.createElement("a");
@@ -550,9 +567,62 @@ function createLink(props) {
550
567
  }
551
568
  return anchor;
552
569
  }
570
+ function clearContainer(container) {
571
+ while (container.firstChild) {
572
+ container.removeChild(container.firstChild);
573
+ }
574
+ }
553
575
  function escapeHtml(str) {
554
576
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
555
577
  }
578
+
579
+ // src/query.ts
580
+ var import_utopia_core3 = require("@matthesketh/utopia-core");
581
+ var queryParams = (0, import_utopia_core3.computed)(() => {
582
+ const match = currentRoute();
583
+ if (!match) return {};
584
+ const result = {};
585
+ match.url.searchParams.forEach((value, key) => {
586
+ result[key] = value;
587
+ });
588
+ return result;
589
+ });
590
+ function getQueryParam(name) {
591
+ return (0, import_utopia_core3.computed)(() => {
592
+ const match = currentRoute();
593
+ if (!match) return null;
594
+ return match.url.searchParams.get(name);
595
+ });
596
+ }
597
+ function setQueryParam(name, value) {
598
+ if (typeof window === "undefined") return;
599
+ const url = new URL(window.location.href);
600
+ if (value === null) {
601
+ url.searchParams.delete(name);
602
+ } else {
603
+ url.searchParams.set(name, value);
604
+ }
605
+ navigate(url.pathname + url.search + url.hash, { replace: true });
606
+ }
607
+ function setQueryParams(params) {
608
+ if (typeof window === "undefined") return;
609
+ const url = new URL(window.location.href);
610
+ for (const [key, value] of Object.entries(params)) {
611
+ if (value === null) {
612
+ url.searchParams.delete(key);
613
+ } else {
614
+ url.searchParams.set(key, value);
615
+ }
616
+ }
617
+ navigate(url.pathname + url.search + url.hash, { replace: true });
618
+ }
619
+ function getRouteParam(name) {
620
+ return (0, import_utopia_core3.computed)(() => {
621
+ const match = currentRoute();
622
+ if (!match) return null;
623
+ return match.params[name] ?? null;
624
+ });
625
+ }
556
626
  // Annotate the CommonJS export names for ESM import in node:
557
627
  0 && (module.exports = {
558
628
  back,
@@ -566,7 +636,12 @@ function escapeHtml(str) {
566
636
  destroy,
567
637
  filePathToRoute,
568
638
  forward,
639
+ getQueryParam,
640
+ getRouteParam,
569
641
  isNavigating,
570
642
  matchRoute,
571
- navigate
643
+ navigate,
644
+ queryParams,
645
+ setQueryParam,
646
+ setQueryParams
572
647
  });
package/dist/index.d.cts CHANGED
@@ -171,8 +171,8 @@ declare function destroy(): void;
171
171
  * Creates a DOM node that renders the current route's component.
172
172
  *
173
173
  * When the route changes:
174
- * 1. The old component is unmounted (removed from DOM)
175
- * 2. The new component is lazily loaded
174
+ * 1. The new component is lazily loaded (old content stays visible)
175
+ * 2. Once loaded, the old component is swapped out atomically
176
176
  * 3. If the route has a layout, the page is wrapped in the layout
177
177
  * 4. If loading fails and an error component exists, it is shown instead
178
178
  *
@@ -214,4 +214,29 @@ declare function createLink(props: {
214
214
  activeClass?: string;
215
215
  }): HTMLAnchorElement;
216
216
 
217
- export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, isNavigating, matchRoute, navigate };
217
+ /**
218
+ * Reactive computed signal returning all current query parameters as a plain object.
219
+ */
220
+ declare const queryParams: _matthesketh_utopia_core.ReadonlySignal<Record<string, string>>;
221
+ /**
222
+ * Returns a computed signal for a specific query parameter.
223
+ * Returns null if the parameter is not present.
224
+ */
225
+ declare function getQueryParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
226
+ /**
227
+ * Update a single query parameter and navigate (replace mode).
228
+ * Pass null to remove the parameter.
229
+ */
230
+ declare function setQueryParam(name: string, value: string | null): void;
231
+ /**
232
+ * Update multiple query parameters in a single navigation.
233
+ * Pass null for a value to remove that parameter.
234
+ */
235
+ declare function setQueryParams(params: Record<string, string | null>): void;
236
+ /**
237
+ * Returns a computed signal for a specific route path parameter.
238
+ * Returns null if the parameter is not present.
239
+ */
240
+ declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
241
+
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 };
package/dist/index.d.ts CHANGED
@@ -171,8 +171,8 @@ declare function destroy(): void;
171
171
  * Creates a DOM node that renders the current route's component.
172
172
  *
173
173
  * When the route changes:
174
- * 1. The old component is unmounted (removed from DOM)
175
- * 2. The new component is lazily loaded
174
+ * 1. The new component is lazily loaded (old content stays visible)
175
+ * 2. Once loaded, the old component is swapped out atomically
176
176
  * 3. If the route has a layout, the page is wrapped in the layout
177
177
  * 4. If loading fails and an error component exists, it is shown instead
178
178
  *
@@ -214,4 +214,29 @@ declare function createLink(props: {
214
214
  activeClass?: string;
215
215
  }): HTMLAnchorElement;
216
216
 
217
- export { type BeforeNavigateHook, type Route, type RouteMatch, type RouterState, back, beforeNavigate, buildRouteTable, compilePattern, createLink, createRouter, createRouterView, currentRoute, destroy, filePathToRoute, forward, isNavigating, matchRoute, navigate };
217
+ /**
218
+ * Reactive computed signal returning all current query parameters as a plain object.
219
+ */
220
+ declare const queryParams: _matthesketh_utopia_core.ReadonlySignal<Record<string, string>>;
221
+ /**
222
+ * Returns a computed signal for a specific query parameter.
223
+ * Returns null if the parameter is not present.
224
+ */
225
+ declare function getQueryParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
226
+ /**
227
+ * Update a single query parameter and navigate (replace mode).
228
+ * Pass null to remove the parameter.
229
+ */
230
+ declare function setQueryParam(name: string, value: string | null): void;
231
+ /**
232
+ * Update multiple query parameters in a single navigation.
233
+ * Pass null for a value to remove that parameter.
234
+ */
235
+ declare function setQueryParams(params: Record<string, string | null>): void;
236
+ /**
237
+ * Returns a computed signal for a specific route path parameter.
238
+ * Returns null if the parameter is not present.
239
+ */
240
+ declare function getRouteParam(name: string): _matthesketh_utopia_core.ReadonlySignal<string | null>;
241
+
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 };
package/dist/index.js CHANGED
@@ -362,33 +362,41 @@ function createRouterView() {
362
362
  container.setAttribute("data-utopia-router-view", "");
363
363
  let currentCleanup = null;
364
364
  let currentMatch = null;
365
+ let loadId = 0;
365
366
  effect(() => {
366
367
  const match = currentRoute();
367
368
  if (match === currentMatch) {
368
369
  return;
369
370
  }
370
371
  currentMatch = match;
371
- if (currentCleanup) {
372
- currentCleanup();
373
- currentCleanup = null;
374
- }
375
- while (container.firstChild) {
376
- container.removeChild(container.firstChild);
377
- }
372
+ const thisLoadId = ++loadId;
378
373
  if (!match) {
374
+ if (currentCleanup) {
375
+ currentCleanup();
376
+ currentCleanup = null;
377
+ }
378
+ clearContainer(container);
379
379
  const notFound = document.createElement("div");
380
380
  notFound.setAttribute("data-utopia-not-found", "");
381
381
  notFound.textContent = "Page not found";
382
382
  container.appendChild(notFound);
383
383
  return;
384
384
  }
385
- loadRouteComponent(match, container).then((cleanupFn) => {
386
- currentCleanup = cleanupFn;
385
+ loadRouteComponent(match).then((result) => {
386
+ if (thisLoadId !== loadId) return;
387
+ if (!result) return;
388
+ if (currentCleanup) {
389
+ currentCleanup();
390
+ currentCleanup = null;
391
+ }
392
+ clearContainer(container);
393
+ container.appendChild(result.node);
394
+ currentCleanup = result.cleanup;
387
395
  });
388
396
  });
389
397
  return container;
390
398
  }
391
- async function loadRouteComponent(match, container) {
399
+ async function loadRouteComponent(match) {
392
400
  try {
393
401
  const promises = [match.route.component()];
394
402
  if (match.route.layout) {
@@ -402,26 +410,26 @@ async function loadRouteComponent(match, container) {
402
410
  }
403
411
  const PageComponent = pageModule.default ?? pageModule;
404
412
  const LayoutComponent = layoutModule ? layoutModule.default ?? layoutModule : null;
405
- while (container.firstChild) {
406
- container.removeChild(container.firstChild);
407
- }
408
413
  const pageNode = renderComponent(PageComponent, {
409
414
  params: match.params,
410
415
  url: match.url
411
416
  });
417
+ let node;
412
418
  if (LayoutComponent) {
413
- const layoutNode = renderComponent(LayoutComponent, {
419
+ node = renderComponent(LayoutComponent, {
414
420
  params: match.params,
415
421
  url: match.url,
416
422
  children: pageNode
417
423
  });
418
- container.appendChild(layoutNode);
419
424
  } else {
420
- container.appendChild(pageNode);
425
+ node = pageNode;
421
426
  }
422
- return () => {
423
- while (container.firstChild) {
424
- container.removeChild(container.firstChild);
427
+ return {
428
+ node,
429
+ cleanup: () => {
430
+ if (node.parentNode) {
431
+ node.parentNode.removeChild(node);
432
+ }
425
433
  }
426
434
  };
427
435
  } catch (err) {
@@ -429,26 +437,33 @@ async function loadRouteComponent(match, container) {
429
437
  try {
430
438
  const errorModule = await match.route.error();
431
439
  const ErrorComponent = errorModule.default ?? errorModule;
432
- while (container.firstChild) {
433
- container.removeChild(container.firstChild);
434
- }
435
440
  const errorNode = renderComponent(ErrorComponent, {
436
441
  error: err,
437
442
  params: match.params,
438
443
  url: match.url
439
444
  });
440
- container.appendChild(errorNode);
445
+ return {
446
+ node: errorNode,
447
+ cleanup: () => {
448
+ if (errorNode.parentNode) {
449
+ errorNode.parentNode.removeChild(errorNode);
450
+ }
451
+ }
452
+ };
441
453
  } catch {
442
- renderFallbackError(container, err);
454
+ return {
455
+ node: createFallbackErrorNode(err),
456
+ cleanup: () => {
457
+ }
458
+ };
443
459
  }
444
460
  } else {
445
- renderFallbackError(container, err);
461
+ return {
462
+ node: createFallbackErrorNode(err),
463
+ cleanup: () => {
464
+ }
465
+ };
446
466
  }
447
- return () => {
448
- while (container.firstChild) {
449
- container.removeChild(container.firstChild);
450
- }
451
- };
452
467
  }
453
468
  }
454
469
  function renderComponent(component, props) {
@@ -471,10 +486,7 @@ function renderComponent(component, props) {
471
486
  div.textContent = "[Component render error]";
472
487
  return div;
473
488
  }
474
- function renderFallbackError(container, error) {
475
- while (container.firstChild) {
476
- container.removeChild(container.firstChild);
477
- }
489
+ function createFallbackErrorNode(error) {
478
490
  const errorDiv = document.createElement("div");
479
491
  errorDiv.setAttribute("data-utopia-error", "");
480
492
  errorDiv.style.cssText = "padding:2rem;color:#dc2626;font-family:monospace;";
@@ -484,7 +496,7 @@ function renderFallbackError(container, error) {
484
496
  error instanceof Error ? error.message : String(error)
485
497
  )}</pre>
486
498
  `;
487
- container.appendChild(errorDiv);
499
+ return errorDiv;
488
500
  }
489
501
  function createLink(props) {
490
502
  const anchor = document.createElement("a");
@@ -511,9 +523,62 @@ function createLink(props) {
511
523
  }
512
524
  return anchor;
513
525
  }
526
+ function clearContainer(container) {
527
+ while (container.firstChild) {
528
+ container.removeChild(container.firstChild);
529
+ }
530
+ }
514
531
  function escapeHtml(str) {
515
532
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
516
533
  }
534
+
535
+ // src/query.ts
536
+ import { computed } from "@matthesketh/utopia-core";
537
+ var queryParams = computed(() => {
538
+ const match = currentRoute();
539
+ if (!match) return {};
540
+ const result = {};
541
+ match.url.searchParams.forEach((value, key) => {
542
+ result[key] = value;
543
+ });
544
+ return result;
545
+ });
546
+ function getQueryParam(name) {
547
+ return computed(() => {
548
+ const match = currentRoute();
549
+ if (!match) return null;
550
+ return match.url.searchParams.get(name);
551
+ });
552
+ }
553
+ function setQueryParam(name, value) {
554
+ if (typeof window === "undefined") return;
555
+ const url = new URL(window.location.href);
556
+ if (value === null) {
557
+ url.searchParams.delete(name);
558
+ } else {
559
+ url.searchParams.set(name, value);
560
+ }
561
+ navigate(url.pathname + url.search + url.hash, { replace: true });
562
+ }
563
+ function setQueryParams(params) {
564
+ if (typeof window === "undefined") return;
565
+ const url = new URL(window.location.href);
566
+ for (const [key, value] of Object.entries(params)) {
567
+ if (value === null) {
568
+ url.searchParams.delete(key);
569
+ } else {
570
+ url.searchParams.set(key, value);
571
+ }
572
+ }
573
+ navigate(url.pathname + url.search + url.hash, { replace: true });
574
+ }
575
+ function getRouteParam(name) {
576
+ return computed(() => {
577
+ const match = currentRoute();
578
+ if (!match) return null;
579
+ return match.params[name] ?? null;
580
+ });
581
+ }
517
582
  export {
518
583
  back,
519
584
  beforeNavigate,
@@ -526,7 +591,12 @@ export {
526
591
  destroy,
527
592
  filePathToRoute,
528
593
  forward,
594
+ getQueryParam,
595
+ getRouteParam,
529
596
  isNavigating,
530
597
  matchRoute,
531
- navigate
598
+ navigate,
599
+ queryParams,
600
+ setQueryParam,
601
+ setQueryParams
532
602
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/utopia-router",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "File-based routing for UtopiaJS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,8 +40,8 @@
40
40
  "dist"
41
41
  ],
42
42
  "dependencies": {
43
- "@matthesketh/utopia-core": "0.3.0",
44
- "@matthesketh/utopia-runtime": "0.3.0"
43
+ "@matthesketh/utopia-core": "0.4.0",
44
+ "@matthesketh/utopia-runtime": "0.4.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "tsup src/index.ts --format esm,cjs --dts",