@wcstack/router 1.10.4 → 1.11.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.esm.js CHANGED
@@ -63,7 +63,10 @@ function getUUID() {
63
63
  });
64
64
  }
65
65
 
66
- function raiseError(message) {
66
+ function raiseError(message, options) {
67
+ if (options && 'cause' in options) {
68
+ throw new Error(`[@wcstack/router] ${message}`, { cause: options.cause });
69
+ }
67
70
  throw new Error(`[@wcstack/router] ${message}`);
68
71
  }
69
72
 
@@ -190,6 +193,7 @@ class RouteCore extends EventTarget {
190
193
  _guardFallbackPath = '';
191
194
  _waitForSetGuardHandler = null;
192
195
  _resolveSetGuardHandler = null;
196
+ _guardHandlerLoadFailed = false;
193
197
  constructor(target) {
194
198
  super();
195
199
  this._target = target ?? this;
@@ -328,6 +332,14 @@ class RouteCore extends EventTarget {
328
332
  });
329
333
  }
330
334
  parsePath(path, options = {}) {
335
+ // 連続呼び出し時のセグメント累積を防ぐためリセット
336
+ this._segmentInfos = [];
337
+ this._absoluteSegmentInfos = undefined;
338
+ this._paramNames = undefined;
339
+ this._absoluteParamNames = undefined;
340
+ this._weight = undefined;
341
+ this._absoluteWeight = undefined;
342
+ this._segmentCount = undefined;
331
343
  this._path = path;
332
344
  this._name = options.name || '';
333
345
  this._isFallbackRoute = options.isFallback || false;
@@ -397,6 +409,7 @@ class RouteCore extends EventTarget {
397
409
  }
398
410
  }
399
411
  setParams(params, typedParams) {
412
+ const wasActive = this._active;
400
413
  this._params = params;
401
414
  this._typedParams = typedParams;
402
415
  this._active = true;
@@ -404,11 +417,24 @@ class RouteCore extends EventTarget {
404
417
  detail: { params, typedParams },
405
418
  bubbles: true,
406
419
  }));
420
+ if (!wasActive) {
421
+ this._target.dispatchEvent(new CustomEvent("wcs-route:active-changed", {
422
+ detail: true,
423
+ bubbles: true,
424
+ }));
425
+ }
407
426
  }
408
427
  clearParams() {
428
+ const wasActive = this._active;
409
429
  this._params = {};
410
430
  this._typedParams = {};
411
431
  this._active = false;
432
+ if (wasActive) {
433
+ this._target.dispatchEvent(new CustomEvent("wcs-route:active-changed", {
434
+ detail: false,
435
+ bubbles: true,
436
+ }));
437
+ }
412
438
  }
413
439
  shouldChange(newParams) {
414
440
  for (const key of this.paramNames) {
@@ -425,8 +451,16 @@ class RouteCore extends EventTarget {
425
451
  return this._guardHandler;
426
452
  }
427
453
  set guardHandler(value) {
428
- this._resolveSetGuardHandler?.();
429
454
  this._guardHandler = value;
455
+ this._resolveSetGuardHandler?.();
456
+ }
457
+ /**
458
+ * Guardハンドラのロードに失敗したことを通知し、guardCheck の待ちを解除する。
459
+ * 解除後の guardCheck は guardHandler が未設定のため fallback パスへリダイレクトする。
460
+ */
461
+ notifyGuardHandlerLoadFailed() {
462
+ this._guardHandlerLoadFailed = true;
463
+ this._resolveSetGuardHandler?.();
430
464
  }
431
465
  async guardCheck(matchResult) {
432
466
  if (this._hasGuard && this._waitForSetGuardHandler) {
@@ -440,6 +474,10 @@ class RouteCore extends EventTarget {
440
474
  throw new GuardCancel('Navigation cancelled by guard.', this._guardFallbackPath);
441
475
  }
442
476
  }
477
+ else if (this._hasGuard && this._guardHandlerLoadFailed) {
478
+ // guardHandler のロードに失敗した場合は fallback パスへ
479
+ throw new GuardCancel('Navigation cancelled: guard handler failed to load.', this._guardFallbackPath);
480
+ }
443
481
  }
444
482
  }
445
483
 
@@ -454,6 +492,7 @@ class Route extends HTMLElement {
454
492
  _childNodeArray;
455
493
  _childIndex = 0;
456
494
  _initialized = false;
495
+ _routes;
457
496
  constructor() {
458
497
  super();
459
498
  this._core = new RouteCore(this);
@@ -484,12 +523,14 @@ class Route extends HTMLElement {
484
523
  return this._childNodeArray;
485
524
  }
486
525
  get routes() {
487
- if (this.routeParentNode) {
488
- return this.routeParentNode.routes.concat(this);
489
- }
490
- else {
491
- return [this];
526
+ // matchRoutes / testPath のホットパスで再帰的に呼ばれるため遅延キャッシュする。
527
+ // initialize 後は routeParentNode が固定されるためキャッシュしても安全。
528
+ if (typeof this._routes === 'undefined') {
529
+ this._routes = this.routeParentNode
530
+ ? this.routeParentNode.routes.concat(this)
531
+ : [this];
492
532
  }
533
+ return this._routes;
493
534
  }
494
535
  get childIndex() {
495
536
  return this._childIndex;
@@ -558,6 +599,17 @@ class Route extends HTMLElement {
558
599
  async guardCheck(matchResult) {
559
600
  return this._core.guardCheck(matchResult);
560
601
  }
602
+ notifyGuardHandlerLoadFailed() {
603
+ this._core.notifyGuardHandlerLoadFailed();
604
+ }
605
+ /**
606
+ * Shell(Route)の routeParentNode を辿って祖先関係を判定する。
607
+ *
608
+ * 責務の分担:
609
+ * - Route(このクラス)は DOM ツリー上の親子関係(routeParentNode)を管理する。
610
+ * - RouteCore はパスやパラメータといった論理的な親子関係(parentCore)を管理する。
611
+ * DOM ツリーは Shell 層、論理ツリーは Core 層という分離のため、両者を独立に保持する。
612
+ */
561
613
  testAncestorNode(ancestorNode) {
562
614
  let currentNode = this._routeParentNode;
563
615
  while (currentNode) {
@@ -626,7 +678,6 @@ class Route extends HTMLElement {
626
678
  const cache = new Map();
627
679
  class Layout extends HTMLElement {
628
680
  _uuid = getUUID();
629
- _initialized = false;
630
681
  constructor() {
631
682
  super();
632
683
  }
@@ -641,7 +692,8 @@ class Layout extends HTMLElement {
641
692
  return templateContent;
642
693
  }
643
694
  catch (error) {
644
- raiseError(`${config.tagNames.layout} failed to load layout from source: ${source}, error: ${error}`);
695
+ // 元の例外を cause として伝播し、スタックトレースを保持する
696
+ raiseError(`${config.tagNames.layout} failed to load layout from source: ${source}, error: ${error}`, { cause: error });
645
697
  }
646
698
  }
647
699
  _loadTemplateFromDocument(id) {
@@ -665,8 +717,8 @@ class Layout extends HTMLElement {
665
717
  template.innerHTML = cache.get(source) || '';
666
718
  }
667
719
  else {
720
+ // _loadTemplateFromSource は内部で cache.set を実行する
668
721
  template.innerHTML = await this._loadTemplateFromSource(source) || '';
669
- cache.set(source, template.innerHTML);
670
722
  }
671
723
  }
672
724
  else if (layoutId) {
@@ -696,14 +748,6 @@ class Layout extends HTMLElement {
696
748
  // Layout 要素が DOM に挿入されないケース(parseで置換)でも name を取れるようにする
697
749
  return this.getAttribute('name') || '';
698
750
  }
699
- _initialize() {
700
- this._initialized = true;
701
- }
702
- connectedCallback() {
703
- if (!this._initialized) {
704
- this._initialize();
705
- }
706
- }
707
751
  }
708
752
 
709
753
  class Outlet extends HTMLElement {
@@ -734,8 +778,23 @@ class Outlet extends HTMLElement {
734
778
  set lastRoutes(value) {
735
779
  this._lastRoutes = [...value];
736
780
  }
781
+ /**
782
+ * shadowRoot 有効化判定。Layout と挙動を揃え、属性で個別オーバーライド可能にする。
783
+ * - `enable-shadow-root` 属性あり → true
784
+ * - `disable-shadow-root` 属性あり → false
785
+ * - いずれもなし → config.enableShadowRoot を尊重
786
+ */
787
+ _resolveEnableShadowRoot() {
788
+ if (this.hasAttribute('enable-shadow-root')) {
789
+ return true;
790
+ }
791
+ if (this.hasAttribute('disable-shadow-root')) {
792
+ return false;
793
+ }
794
+ return config.enableShadowRoot;
795
+ }
737
796
  _initialize() {
738
- if (config.enableShadowRoot) {
797
+ if (this._resolveEnableShadowRoot()) {
739
798
  this.attachShadow({ mode: 'open' });
740
799
  }
741
800
  this._initialized = true;
@@ -745,6 +804,22 @@ class Outlet extends HTMLElement {
745
804
  this._initialize();
746
805
  }
747
806
  }
807
+ /**
808
+ * Outlet が disconnect された際の状態クリーンアップ。
809
+ *
810
+ * `_lastRoutes` をクリアすることで、再接続後の applyRoute における diff
811
+ * (既に show 済みのルートは show を skip する判定)が、切断中に外部から
812
+ * 操作された DOM と整合しなくなる事故を防ぐ。
813
+ *
814
+ * 仕様前提として Outlet は Router と一体運用される(Router が `_getOutlet()` で
815
+ * 自身の兄弟に Outlet を配置・参照する)。それでも単独で再接続される
816
+ * エッジケースに備える防衛的措置として `_lastRoutes` のみクリアする。
817
+ * `_initialized` と shadowRoot は維持し、再 attachShadow による
818
+ * InvalidStateError を回避する。
819
+ */
820
+ disconnectedCallback() {
821
+ this._lastRoutes = [];
822
+ }
748
823
  }
749
824
  function createOutlet() {
750
825
  return document.createElement(config.tagNames.outlet);
@@ -779,7 +854,14 @@ function _assignParams(element, params, bindType) {
779
854
  };
780
855
  break;
781
856
  case "attr":
782
- element.setAttribute(key, value);
857
+ // null/undefined は属性削除として扱う(文字列 "null"/"undefined" になる事故を防ぐ)。
858
+ // boolean/number は setAttribute の標準挙動に従って文字列化される。
859
+ if (value === null || value === undefined) {
860
+ element.removeAttribute(key);
861
+ }
862
+ else {
863
+ element.setAttribute(key, String(value));
864
+ }
783
865
  break;
784
866
  case "":
785
867
  element[key] = value;
@@ -798,6 +880,12 @@ function assignParams(element, params) {
798
880
  const bindType = bindTypeText;
799
881
  const customTagName = getCustomTagName(element);
800
882
  if (customTagName && customElements.get(customTagName) === undefined) {
883
+ // 注意: customElements.whenDefined(tag) は当該タグが define されるまで pending のままになる。
884
+ // element が削除されてもこの Promise は GC されず、closure に保持される element/params は
885
+ // 解放されない(弱い参照を持つ手段がないため)。define されないまま要素のみが大量に作られる
886
+ // ようなケースではリークになりうるが、通常の Web Components 利用では autoloader が
887
+ // 一括 define するため実用上問題にならない。明示的にキャンセルしたい場合は将来 AbortSignal を
888
+ // サポートすることを検討する。
801
889
  customElements.whenDefined(customTagName).then(() => {
802
890
  if (element.isConnected) {
803
891
  // 要素が削除されていない場合のみ割り当てを行う
@@ -815,6 +903,8 @@ function assignParams(element, params) {
815
903
  class LayoutOutlet extends HTMLElement {
816
904
  _layout = null;
817
905
  _initialized = false;
906
+ _initializing = false;
907
+ _disconnectedDuringInit = false;
818
908
  _layoutChildNodes = [];
819
909
  constructor() {
820
910
  super();
@@ -833,62 +923,88 @@ class LayoutOutlet extends HTMLElement {
833
923
  return this.layout.name;
834
924
  }
835
925
  async _initialize() {
836
- this._initialized = true;
837
- if (this.layout.enableShadowRoot) {
838
- this.attachShadow({ mode: 'open' });
839
- }
840
- const template = await this.layout.loadTemplate();
841
- if (this.shadowRoot) {
842
- this.shadowRoot.appendChild(template.content.cloneNode(true));
843
- for (const childNode of Array.from(this.layout.childNodes)) {
844
- this._layoutChildNodes.push(childNode);
845
- this.appendChild(childNode);
926
+ this._initializing = true;
927
+ try {
928
+ this._initialized = true;
929
+ // attachShadow は冪等にする: 一度 await loadTemplate() 中に切断されると
930
+ // _initialized = false で戻され、再 connect 時に _initialize() が再度走るが、
931
+ // その時点で shadowRoot は既に存在しているため、再度 attachShadow すると
932
+ // InvalidStateError になる。
933
+ if (this.layout.enableShadowRoot && !this.shadowRoot) {
934
+ this.attachShadow({ mode: 'open' });
846
935
  }
847
- }
848
- else {
849
- const fragmentForTemplate = template.content.cloneNode(true);
850
- const slotElementBySlotName = new Map();
851
- fragmentForTemplate.querySelectorAll('slot').forEach((slotElement) => {
852
- const slotName = slotElement.getAttribute('name') || '';
853
- if (!slotElementBySlotName.has(slotName)) {
854
- slotElementBySlotName.set(slotName, slotElement);
855
- }
856
- else {
857
- console.warn(`${config.tagNames.layoutOutlet} duplicate slot name "${slotName}" in layout template.`);
936
+ const template = await this.layout.loadTemplate();
937
+ // await 中に切断された場合は DOM 副作用を残さず、次回再接続時に再初期化させる
938
+ if (!this.isConnected) {
939
+ this._initialized = false;
940
+ return;
941
+ }
942
+ if (this.shadowRoot) {
943
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
944
+ for (const childNode of Array.from(this.layout.childNodes)) {
945
+ this._layoutChildNodes.push(childNode);
946
+ this.appendChild(childNode);
858
947
  }
859
- });
860
- const fragmentBySlotName = new Map();
861
- const fragmentForChildNodes = document.createDocumentFragment();
862
- for (const childNode of Array.from(this.layout.childNodes)) {
863
- this._layoutChildNodes.push(childNode);
864
- if (childNode instanceof Element) {
865
- const slotName = childNode.getAttribute('slot') || '';
866
- if (slotName.length > 0 && slotElementBySlotName.has(slotName)) {
867
- if (!fragmentBySlotName.has(slotName)) {
868
- fragmentBySlotName.set(slotName, document.createDocumentFragment());
948
+ }
949
+ else {
950
+ const fragmentForTemplate = template.content.cloneNode(true);
951
+ const slotElementBySlotName = new Map();
952
+ fragmentForTemplate.querySelectorAll('slot').forEach((slotElement) => {
953
+ const slotName = slotElement.getAttribute('name') || '';
954
+ if (!slotElementBySlotName.has(slotName)) {
955
+ slotElementBySlotName.set(slotName, slotElement);
956
+ }
957
+ else {
958
+ console.warn(`${config.tagNames.layoutOutlet} duplicate slot name "${slotName}" in layout template.`);
959
+ }
960
+ });
961
+ const fragmentBySlotName = new Map();
962
+ const fragmentForChildNodes = document.createDocumentFragment();
963
+ for (const childNode of Array.from(this.layout.childNodes)) {
964
+ this._layoutChildNodes.push(childNode);
965
+ if (childNode instanceof Element) {
966
+ const slotName = childNode.getAttribute('slot') || '';
967
+ if (slotName.length > 0 && slotElementBySlotName.has(slotName)) {
968
+ if (!fragmentBySlotName.has(slotName)) {
969
+ fragmentBySlotName.set(slotName, document.createDocumentFragment());
970
+ }
971
+ fragmentBySlotName.get(slotName)?.appendChild(childNode);
972
+ continue;
869
973
  }
870
- fragmentBySlotName.get(slotName)?.appendChild(childNode);
871
- continue;
872
974
  }
975
+ fragmentForChildNodes.appendChild(childNode);
873
976
  }
874
- fragmentForChildNodes.appendChild(childNode);
875
- }
876
- for (const [slotName, slotElement] of slotElementBySlotName) {
877
- const fragment = fragmentBySlotName.get(slotName);
878
- if (fragment) {
879
- slotElement.replaceWith(fragment);
977
+ for (const [slotName, slotElement] of slotElementBySlotName) {
978
+ const fragment = fragmentBySlotName.get(slotName);
979
+ if (fragment) {
980
+ slotElement.replaceWith(fragment);
981
+ }
880
982
  }
983
+ const defaultSlot = slotElementBySlotName.get('');
984
+ if (defaultSlot) {
985
+ defaultSlot.replaceWith(fragmentForChildNodes);
986
+ }
987
+ this.appendChild(fragmentForTemplate);
881
988
  }
882
- const defaultSlot = slotElementBySlotName.get('');
883
- if (defaultSlot) {
884
- defaultSlot.replaceWith(fragmentForChildNodes);
885
- }
886
- this.appendChild(fragmentForTemplate);
989
+ }
990
+ finally {
991
+ this._initializing = false;
887
992
  }
888
993
  }
889
994
  async connectedCallback() {
890
995
  if (!this._initialized) {
996
+ this._disconnectedDuringInit = false;
891
997
  await this._initialize();
998
+ // 初期化中(await 中)に切断された場合は副作用を残さない
999
+ if (this._disconnectedDuringInit || !this.isConnected) {
1000
+ return;
1001
+ }
1002
+ }
1003
+ }
1004
+ disconnectedCallback() {
1005
+ // _initialize 中(await 中)に呼ばれた場合はフラグを立てて再 connect 時に init を許可する
1006
+ if (this._initializing) {
1007
+ this._disconnectedDuringInit = true;
892
1008
  }
893
1009
  }
894
1010
  assignParams(params) {
@@ -910,9 +1026,15 @@ function createLayoutOutlet() {
910
1026
  return document.createElement(config.tagNames.layoutOutlet);
911
1027
  }
912
1028
 
913
- async function importModule(script) {
1029
+ async function importModule(script, route) {
914
1030
  let scriptModule = null;
915
- const sourceComment = `\n//# sourceURL=wcs-guard-handler\n`;
1031
+ let firstError = null;
1032
+ // devtools での識別用 sourceURL suffix。
1033
+ // uuid を使う: Route インスタンスでは constructor で getUUID() により必ず設定される。
1034
+ // partial mock 等で undefined の可能性に備えて空文字列フォールバックを置く。
1035
+ const routeTag = route.uuid || "";
1036
+ const sourceURL = routeTag ? `wcs-guard-handler:${routeTag}` : `wcs-guard-handler`;
1037
+ const sourceComment = `\n//# sourceURL=${sourceURL}\n`;
916
1038
  const scriptText = script.text + sourceComment;
917
1039
  if (typeof URL.createObjectURL === 'function') {
918
1040
  const blob = new Blob([scriptText], { type: "application/javascript" });
@@ -920,8 +1042,9 @@ async function importModule(script) {
920
1042
  try {
921
1043
  scriptModule = await import(url);
922
1044
  }
923
- catch {
1045
+ catch (e) {
924
1046
  // Blob URL import failed (e.g. happy-dom), fall through to data: URL
1047
+ firstError = e;
925
1048
  }
926
1049
  finally {
927
1050
  URL.revokeObjectURL(url);
@@ -930,7 +1053,16 @@ async function importModule(script) {
930
1053
  if (!scriptModule) {
931
1054
  // Fallback: Base64 data: URL (for test environments)
932
1055
  const b64 = btoa(String.fromCodePoint(...new TextEncoder().encode(scriptText)));
933
- scriptModule = await import(`data:application/javascript;base64,${b64}`);
1056
+ try {
1057
+ scriptModule = await import(`data:application/javascript;base64,${b64}`);
1058
+ }
1059
+ catch (e) {
1060
+ // 両 import が失敗した場合、Blob URL 側の元エラーを cause として失わないように包む
1061
+ // (Blob URL も失敗していなければ firstError は null)
1062
+ throw new Error(`loadGuardHandler: failed to import guard script. ` +
1063
+ `data: URL error: ${e?.message ?? String(e)}` +
1064
+ (firstError ? `. Blob URL error: ${firstError?.message ?? String(firstError)}` : ''), { cause: firstError ?? e });
1065
+ }
934
1066
  }
935
1067
  if (scriptModule && typeof scriptModule.default === 'function') {
936
1068
  return scriptModule.default;
@@ -938,13 +1070,28 @@ async function importModule(script) {
938
1070
  return null;
939
1071
  }
940
1072
  function loadGuardHandler(script, route) {
941
- importModule(script).then(handler => {
1073
+ importModule(script, route).then(handler => {
942
1074
  if (handler) {
943
1075
  route.guardHandler = handler;
944
1076
  }
1077
+ else {
1078
+ // ハンドラが取得できなかった場合は guardCheck の待ちを解除する
1079
+ route.notifyGuardHandlerLoadFailed();
1080
+ }
1081
+ }).catch(err => {
1082
+ console.error('loadGuardHandler failed:', err);
1083
+ // import 失敗時も guardCheck の待ちを解除する
1084
+ route.notifyGuardHandlerLoadFailed();
945
1085
  });
946
1086
  }
947
1087
 
1088
+ /**
1089
+ * 同一の絶対パスを持つ Route が複数定義された場合に警告を出力する。
1090
+ *
1091
+ * 仕様: 同一 absolutePath ごとに 1 回だけ警告する(複数重複でも警告は 1 件)。
1092
+ * これは過剰なログを避けるための意図的な設計。
1093
+ * テストでは Vitest の console.warn spy で 1 回出力を確認する。
1094
+ */
948
1095
  function _duplicateCheck(routesByPath, route) {
949
1096
  let routes = routesByPath.get(route.absolutePath);
950
1097
  if (!routes) {
@@ -961,7 +1108,7 @@ function _duplicateCheck(routesByPath, route) {
961
1108
  routesByPath.set(route.absolutePath, routes);
962
1109
  }
963
1110
  }
964
- async function _parseNode(routerNode, node, routes, map, routesByPath) {
1111
+ async function _parseNode(routerNode, node, routes, routesByPath) {
965
1112
  const routeParentNode = routes.length > 0 ? routes[routes.length - 1] : null;
966
1113
  const fragment = document.createDocumentFragment();
967
1114
  const childNodes = Array.from(node.childNodes);
@@ -983,7 +1130,6 @@ async function _parseNode(routerNode, node, routes, map, routesByPath) {
983
1130
  route.initialize(routerNode, routeParentNode);
984
1131
  _duplicateCheck(routesByPath, route);
985
1132
  routes.push(route);
986
- map.set(route.uuid, route);
987
1133
  appendNode = route.placeHolder;
988
1134
  element = route;
989
1135
  }
@@ -998,6 +1144,11 @@ async function _parseNode(routerNode, node, routes, map, routesByPath) {
998
1144
  continue;
999
1145
  }
1000
1146
  else if (tagName === config.tagNames.layout) {
1147
+ // <wcs-layout> は他の case と異なり element と appendNode が別物になる。
1148
+ // - element: cloneElement (Layout 本体)。後続の `element.innerHTML = ""; element.appendChild(children)`
1149
+ // で再帰結果が Layout 内に流し込まれる。Layout はそれを slot 投影に使う。
1150
+ // - appendNode: layoutOutlet。最終的に fragment へ挿入されるのは layoutOutlet で、
1151
+ // layoutOutlet が element (Layout) を参照して投影を行う。
1001
1152
  const childFragment = document.createDocumentFragment();
1002
1153
  // Move child nodes to fragment to avoid duplication of
1003
1154
  for (const childNode of Array.from(element.childNodes)) {
@@ -1012,7 +1163,7 @@ async function _parseNode(routerNode, node, routes, map, routesByPath) {
1012
1163
  appendNode = layoutOutlet;
1013
1164
  element = cloneElement;
1014
1165
  }
1015
- const children = await _parseNode(routerNode, element, routes, map, routesByPath);
1166
+ const children = await _parseNode(routerNode, element, routes, routesByPath);
1016
1167
  element.innerHTML = "";
1017
1168
  element.appendChild(children);
1018
1169
  fragment.appendChild(appendNode);
@@ -1024,9 +1175,8 @@ async function _parseNode(routerNode, node, routes, map, routesByPath) {
1024
1175
  return fragment;
1025
1176
  }
1026
1177
  async function parse(routerNode) {
1027
- const map = new Map();
1028
1178
  const routesByPath = new Map();
1029
- const fr = await _parseNode(routerNode, routerNode.template.content, [], map, routesByPath);
1179
+ const fr = await _parseNode(routerNode, routerNode.template.content, [], routesByPath);
1030
1180
  return fr;
1031
1181
  }
1032
1182
 
@@ -1114,18 +1264,16 @@ function testPath(route, path, segments) {
1114
1264
  return null;
1115
1265
  }
1116
1266
 
1117
- function _matchRoutes(routerNode, routeNode, routes, normalizedPath, segments, results) {
1118
- const nextRoutes = routes.concat(routeNode);
1267
+ function _matchRoutes(routeNode, normalizedPath, segments, results) {
1119
1268
  const matchResult = testPath(routeNode, normalizedPath, segments);
1120
1269
  if (matchResult) {
1121
1270
  results.push(matchResult);
1122
1271
  }
1123
1272
  for (const childRoute of routeNode.routeChildNodes) {
1124
- _matchRoutes(routerNode, childRoute, nextRoutes, normalizedPath, segments, results);
1273
+ _matchRoutes(childRoute, normalizedPath, segments, results);
1125
1274
  }
1126
1275
  }
1127
1276
  function matchRoutes(routerNode, normalizedPath) {
1128
- const routes = [];
1129
1277
  const topLevelRoutes = routerNode.routeChildNodes;
1130
1278
  const results = [];
1131
1279
  // セグメント配列を作成(先頭の/は除去せずにそのまま分割)
@@ -1143,7 +1291,7 @@ function matchRoutes(routerNode, normalizedPath) {
1143
1291
  return true;
1144
1292
  });
1145
1293
  for (const route of topLevelRoutes) {
1146
- _matchRoutes(routerNode, route, routes, normalizedPath, segments, results);
1294
+ _matchRoutes(route, normalizedPath, segments, results);
1147
1295
  }
1148
1296
  results.sort((a, b) => {
1149
1297
  const lastRouteA = a.routes.at(-1);
@@ -1210,6 +1358,14 @@ function showRoute(route, matchResult) {
1210
1358
  return true;
1211
1359
  }
1212
1360
 
1361
+ /**
1362
+ * ルートコンテンツを表示する。
1363
+ *
1364
+ * @returns ガードチェックを通過してコンテンツ表示が成立した場合 true、
1365
+ * GuardCancel により中断(フォールバックへ再ナビゲート)した場合 false。
1366
+ * 呼び出し側(applyRoute)は false の場合、router.path / outlet.lastRoutes を
1367
+ * 更新しないことで「拒否されたパスでの path-changed 発火」を防ぐ。
1368
+ */
1213
1369
  async function showRouteContent(routerNode, matchResult, lastRoutes) {
1214
1370
  // Hide previous routes
1215
1371
  const routesSet = new Set(matchResult.routes);
@@ -1224,14 +1380,14 @@ async function showRouteContent(routerNode, matchResult, lastRoutes) {
1224
1380
  }
1225
1381
  }
1226
1382
  catch (e) {
1227
- const err = e;
1228
- if ("fallbackPath" in err) {
1229
- const guardCancel = err;
1230
- console.warn(`Navigation cancelled: ${err.message}. Redirecting to ${guardCancel.fallbackPath}`);
1383
+ if (e instanceof GuardCancel) {
1384
+ console.warn(`Navigation cancelled: ${e.message}. Redirecting to ${e.fallbackPath}`);
1231
1385
  queueMicrotask(() => {
1232
- routerNode.navigate(guardCancel.fallbackPath);
1386
+ routerNode.navigate(e.fallbackPath).catch((err) => {
1387
+ console.error('Fallback navigation failed:', err);
1388
+ });
1233
1389
  });
1234
- return;
1390
+ return false;
1235
1391
  }
1236
1392
  else {
1237
1393
  throw e;
@@ -1244,6 +1400,7 @@ async function showRouteContent(routerNode, matchResult, lastRoutes) {
1244
1400
  force = showRoute(route, matchResult);
1245
1401
  }
1246
1402
  }
1403
+ return true;
1247
1404
  }
1248
1405
 
1249
1406
  async function applyRoute(routerNode, outlet, fullPath, lastPath) {
@@ -1276,7 +1433,11 @@ async function applyRoute(routerNode, outlet, fullPath, lastPath) {
1276
1433
  }
1277
1434
  matchResult.lastPath = lastPath;
1278
1435
  const lastRoutes = outlet.lastRoutes;
1279
- await showRouteContent(routerNode, matchResult, lastRoutes);
1436
+ const committed = await showRouteContent(routerNode, matchResult, lastRoutes);
1437
+ // GuardCancel により中断された場合は state を更新しない
1438
+ // (拒否されたパスでの wcs-router:path-changed 発火を防ぐため)
1439
+ if (!committed)
1440
+ return;
1280
1441
  // if successful, update router and outlet state
1281
1442
  routerNode.path = path;
1282
1443
  outlet.lastRoutes = matchResult.routes;
@@ -1293,6 +1454,70 @@ function getNavigation() {
1293
1454
  return nav;
1294
1455
  }
1295
1456
 
1457
+ // basenameFileExtensions ベースの正規表現をキャッシュ(config 変更時のみ再生成)。
1458
+ let _cachedExtensions = null;
1459
+ let _cachedExtPattern = null;
1460
+ /**
1461
+ * config.basenameFileExtensions から拡張子削除用の正規表現を生成(キャッシュ付き)。
1462
+ * config 変更が検知された場合のみ再生成する。
1463
+ */
1464
+ function getExtPattern() {
1465
+ const exts = config.basenameFileExtensions;
1466
+ if (exts.length === 0)
1467
+ return null;
1468
+ if (_cachedExtensions === exts && _cachedExtPattern) {
1469
+ return _cachedExtPattern;
1470
+ }
1471
+ _cachedExtensions = exts;
1472
+ _cachedExtPattern = new RegExp(`\\/[^/]+(?:${exts.map(e => e.replace(/\./g, '\\.')).join('|')})$`, 'i');
1473
+ return _cachedExtPattern;
1474
+ }
1475
+ /**
1476
+ * URL pathname を route path に正規化する。
1477
+ * - 先頭スラッシュを保証
1478
+ * - 連続スラッシュを単一化
1479
+ * - 末尾のファイル拡張子(例: .html)をディレクトリルートとして扱う
1480
+ * - ルート以外の末尾スラッシュを除去
1481
+ */
1482
+ function normalizePathname(path) {
1483
+ let p = path || "/";
1484
+ if (!p.startsWith("/"))
1485
+ p = "/" + p;
1486
+ p = p.replace(/\/{2,}/g, "/");
1487
+ const extPattern = getExtPattern();
1488
+ if (extPattern) {
1489
+ p = p.replace(extPattern, "");
1490
+ }
1491
+ if (p === "")
1492
+ p = "/";
1493
+ if (p.length > 1 && p.endsWith("/"))
1494
+ p = p.slice(0, -1);
1495
+ return p;
1496
+ }
1497
+ /**
1498
+ * basename を正規化する。
1499
+ * - "" or "/" -> ""
1500
+ * - "/app/" -> "/app"
1501
+ * - "/app/index.html" -> "/app"
1502
+ */
1503
+ function normalizeBasename(path) {
1504
+ let p = path || "";
1505
+ if (!p)
1506
+ return "";
1507
+ if (!p.startsWith("/"))
1508
+ p = "/" + p;
1509
+ p = p.replace(/\/{2,}/g, "/");
1510
+ const extPattern = getExtPattern();
1511
+ if (extPattern) {
1512
+ p = p.replace(extPattern, "");
1513
+ }
1514
+ if (p.length > 1 && p.endsWith("/"))
1515
+ p = p.slice(0, -1);
1516
+ if (p === "/")
1517
+ return "";
1518
+ return p;
1519
+ }
1520
+
1296
1521
  /**
1297
1522
  * AppRoutes - Root component for @wcstack/router
1298
1523
  *
@@ -1306,6 +1531,12 @@ class Router extends HTMLElement {
1306
1531
  { name: "navigateUrl", event: "wcs-router:navigate-url-changed" },
1307
1532
  { name: "path", event: "wcs-router:path-changed" },
1308
1533
  ],
1534
+ inputs: [
1535
+ { name: "basename", attribute: "basename" },
1536
+ ],
1537
+ commands: [
1538
+ { name: "navigate", async: true },
1539
+ ],
1309
1540
  };
1310
1541
  _outlet = null;
1311
1542
  _template = null;
@@ -1315,57 +1546,26 @@ class Router extends HTMLElement {
1315
1546
  _initialized = false;
1316
1547
  _fallbackRoute = null;
1317
1548
  _listeningPopState = false;
1549
+ _listeningNavigate = false;
1318
1550
  _navigateUrl = null;
1551
+ _disconnectedDuringInit = false;
1552
+ _initializing = false;
1319
1553
  constructor() {
1320
1554
  super();
1321
1555
  }
1322
1556
  /**
1323
1557
  * Normalize a URL pathname to a route path.
1324
- * - ensure leading slash
1325
- * - collapse multiple slashes
1326
- * - treat trailing file extensions (e.g. .html) as directory root
1327
- * - remove trailing slash except root "/"
1558
+ * 共通実装は normalizePathname.ts を参照(Link との挙動整合のため)。
1328
1559
  */
1329
1560
  _normalizePathname(_path) {
1330
- let path = _path || "/";
1331
- if (!path.startsWith("/"))
1332
- path = "/" + path;
1333
- path = path.replace(/\/{2,}/g, "/");
1334
- // e.g. "/app/index.html" -> "/app"
1335
- const exts = config.basenameFileExtensions;
1336
- if (exts.length > 0) {
1337
- const extPattern = new RegExp(`\\/[^/]+(?:${exts.map(e => e.replace(/\./g, '\\.')).join('|')})$`, 'i');
1338
- path = path.replace(extPattern, "");
1339
- }
1340
- if (path === "")
1341
- path = "/";
1342
- if (path.length > 1 && path.endsWith("/"))
1343
- path = path.slice(0, -1);
1344
- return path;
1561
+ return normalizePathname(_path);
1345
1562
  }
1346
1563
  /**
1347
1564
  * Normalize basename.
1348
- * - "" or "/" -> ""
1349
- * - "/app/" -> "/app"
1350
- * - "/app/index.html" -> "/app"
1565
+ * 共通実装は normalizePathname.ts を参照。
1351
1566
  */
1352
1567
  _normalizeBasename(_path) {
1353
- let path = _path || "";
1354
- if (!path)
1355
- return "";
1356
- if (!path.startsWith("/"))
1357
- path = "/" + path;
1358
- path = path.replace(/\/{2,}/g, "/");
1359
- const exts = config.basenameFileExtensions;
1360
- if (exts.length > 0) {
1361
- const extPattern = new RegExp(`\\/[^/]+(?:${exts.map(e => e.replace(/\./g, '\\.')).join('|')})$`, 'i');
1362
- path = path.replace(extPattern, "");
1363
- }
1364
- if (path.length > 1 && path.endsWith("/"))
1365
- path = path.slice(0, -1);
1366
- if (path === "/")
1367
- return "";
1368
- return path;
1568
+ return normalizeBasename(_path);
1369
1569
  }
1370
1570
  _joinInternalPath(basename, to) {
1371
1571
  const base = this._normalizeBasename(basename);
@@ -1460,8 +1660,13 @@ class Router extends HTMLElement {
1460
1660
  set navigateUrl(value) {
1461
1661
  if (value === null || value === undefined || value === "")
1462
1662
  return;
1663
+ // 既に同一 URL の navigate 中なら再起動しない
1664
+ if (this._navigateUrl === value)
1665
+ return;
1463
1666
  this._navigateUrl = value;
1464
- this.navigate(value).then(() => {
1667
+ this.navigate(value).catch((err) => {
1668
+ console.error(`${config.tagNames.router} navigate failed:`, err);
1669
+ }).finally(() => {
1465
1670
  this._navigateUrl = null;
1466
1671
  this.dispatchEvent(new CustomEvent("wcs-router:navigate-url-changed", {
1467
1672
  detail: null,
@@ -1473,7 +1678,13 @@ class Router extends HTMLElement {
1473
1678
  const fullPath = this._joinInternalPath(this._basename, path);
1474
1679
  const navigation = getNavigation();
1475
1680
  if (navigation?.navigate) {
1476
- navigation.navigate(fullPath);
1681
+ // Navigation API は { committed, finished } を返す。
1682
+ // finished を await することで、navigate() の Promise が
1683
+ // 実際のナビゲーション完了まで pending となり、_navigateUrl の
1684
+ // 二重 navigate ガード (setter 内 `if (this._navigateUrl === value)`) が
1685
+ // 適切な時間ウィンドウで機能するようになる。
1686
+ // Polyfill や mock 環境で undefined / 戻り値なしのケースもあるため optional chaining。
1687
+ await navigation.navigate(fullPath)?.finished;
1477
1688
  }
1478
1689
  else {
1479
1690
  history.pushState(null, '', fullPath);
@@ -1504,7 +1715,13 @@ class Router extends HTMLElement {
1504
1715
  const routesNode = this;
1505
1716
  navEvent.intercept({
1506
1717
  handler: async () => {
1507
- await applyRoute(routesNode, routesNode.outlet, fullPath, routesNode.path);
1718
+ try {
1719
+ await applyRoute(routesNode, routesNode.outlet, fullPath, routesNode.path);
1720
+ }
1721
+ catch (err) {
1722
+ console.error(`${config.tagNames.router} applyRoute failed:`, err);
1723
+ throw err;
1724
+ }
1508
1725
  },
1509
1726
  });
1510
1727
  }
@@ -1519,41 +1736,63 @@ class Router extends HTMLElement {
1519
1736
  this._notifyLocationChange();
1520
1737
  };
1521
1738
  async _initialize() {
1522
- this._initialized = true;
1523
- this._basename = this._normalizeBasename(this.getAttribute("basename") || this._getBasename() || "");
1524
- const hasBaseTag = document.querySelector('base[href]') !== null;
1525
- const url = new URL(window.location.href);
1526
- if (this._basename === "" && !hasBaseTag && url.pathname !== "/") {
1527
- raiseError(`${config.tagNames.router} basename is empty, but current path is not "/".`);
1528
- }
1529
- this._outlet = this._getOutlet();
1530
- this._outlet.routesNode = this;
1531
- this._template = this._getTemplate();
1532
- if (!this._template) {
1533
- raiseError(`${config.tagNames.router} should have a <template> child element.`);
1739
+ this._initializing = true;
1740
+ try {
1741
+ this._basename = this._normalizeBasename(this.getAttribute("basename") || this._getBasename() || "");
1742
+ const hasBaseTag = document.querySelector('base[href]') !== null;
1743
+ const url = new URL(window.location.href);
1744
+ if (this._basename === "" && !hasBaseTag && url.pathname !== "/") {
1745
+ raiseError(`${config.tagNames.router} basename is empty, but current path is not "/".`);
1746
+ }
1747
+ this._outlet = this._getOutlet();
1748
+ this._outlet.routesNode = this;
1749
+ this._template = this._getTemplate();
1750
+ if (!this._template) {
1751
+ raiseError(`${config.tagNames.router} should have a <template> child element.`);
1752
+ }
1753
+ const fragment = await parse(this);
1754
+ this._outlet.rootNode.appendChild(fragment);
1755
+ if (this.routeChildNodes.length === 0) {
1756
+ raiseError(`${config.tagNames.router} has no route definitions.`);
1757
+ }
1758
+ const fullPath = this._normalizePathname(window.location.pathname);
1759
+ await applyRoute(this, this.outlet, fullPath, this._path);
1760
+ this._notifyLocationChange();
1761
+ this._initialized = true;
1534
1762
  }
1535
- const fragment = await parse(this);
1536
- this._outlet.rootNode.appendChild(fragment);
1537
- if (this.routeChildNodes.length === 0) {
1538
- raiseError(`${config.tagNames.router} has no route definitions.`);
1763
+ finally {
1764
+ this._initializing = false;
1539
1765
  }
1540
- const fullPath = this._normalizePathname(window.location.pathname);
1541
- await applyRoute(this, this.outlet, fullPath, this._path);
1542
- this._notifyLocationChange();
1543
1766
  }
1544
1767
  async connectedCallback() {
1545
1768
  if (!this._initialized) {
1769
+ this._disconnectedDuringInit = false;
1546
1770
  await this._initialize();
1771
+ // 初期化中に disconnectedCallback が呼ばれた場合はイベントリスナを登録しない
1772
+ if (this._disconnectedDuringInit) {
1773
+ return;
1774
+ }
1775
+ }
1776
+ const navigation = getNavigation();
1777
+ if (navigation && !this._listeningNavigate) {
1778
+ navigation.addEventListener("navigate", this._onNavigate);
1779
+ this._listeningNavigate = true;
1547
1780
  }
1548
- getNavigation()?.addEventListener("navigate", this._onNavigate);
1549
1781
  // Fallback for browsers without Navigation API
1550
- if (!getNavigation()?.addEventListener && !this._listeningPopState) {
1782
+ if (!navigation && !this._listeningPopState) {
1551
1783
  window.addEventListener("popstate", this._onPopState);
1552
1784
  this._listeningPopState = true;
1553
1785
  }
1554
1786
  }
1555
1787
  disconnectedCallback() {
1556
- getNavigation()?.removeEventListener("navigate", this._onNavigate);
1788
+ // _initialize 中(await 中)に呼ばれた場合はフラグを立ててリスナ登録をスキップさせる
1789
+ if (this._initializing) {
1790
+ this._disconnectedDuringInit = true;
1791
+ }
1792
+ if (this._listeningNavigate) {
1793
+ getNavigation()?.removeEventListener("navigate", this._onNavigate);
1794
+ this._listeningNavigate = false;
1795
+ }
1557
1796
  if (this._listeningPopState) {
1558
1797
  window.removeEventListener("popstate", this._onPopState);
1559
1798
  this._listeningPopState = false;
@@ -1578,6 +1817,13 @@ class Link extends HTMLElement {
1578
1817
  get uuid() {
1579
1818
  return this._uuid;
1580
1819
  }
1820
+ /**
1821
+ * 最寄りの Router を返す。
1822
+ *
1823
+ * 注意: この getter は DOM 走査で Router を探すため、
1824
+ * Router がまだ upgrade されていない場合は HTMLElement として返る可能性がある。
1825
+ * 通常は registerComponents() で Router を Link より先に upgrade することを推奨する。
1826
+ */
1581
1827
  get router() {
1582
1828
  if (this._router) {
1583
1829
  return this._router;
@@ -1601,17 +1847,16 @@ class Link extends HTMLElement {
1601
1847
  this._path = this.getAttribute('to') || '';
1602
1848
  this._initialized = true;
1603
1849
  }
1850
+ /**
1851
+ * URL pathname を正規化する。Router と共通実装を使うことで
1852
+ * basenameFileExtensions の取り扱いを揃え、active 判定の取りこぼしを防ぐ。
1853
+ */
1604
1854
  _normalizePathname(path) {
1605
- let p = path || "/";
1606
- if (!p.startsWith("/"))
1607
- p = "/" + p;
1608
- p = p.replace(/\/{2,}/g, "/");
1609
- if (p.length > 1 && p.endsWith("/"))
1610
- p = p.slice(0, -1);
1611
- return p;
1855
+ return normalizePathname(path);
1612
1856
  }
1613
1857
  _joinInternalPath(basename, to) {
1614
- const base = (basename || "").replace(/\/{2,}/g, "/").replace(/\/$/, "");
1858
+ // Router._joinInternalPath と挙動を揃える
1859
+ const base = normalizeBasename(basename);
1615
1860
  const internal = to.startsWith("/") ? to : "/" + to;
1616
1861
  const path = this._normalizePathname(internal);
1617
1862
  if (!base)
@@ -1669,6 +1914,9 @@ class Link extends HTMLElement {
1669
1914
  return;
1670
1915
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
1671
1916
  return;
1917
+ // 動的に外部URLに変わった場合はブラウザのデフォルト挙動に委ねる
1918
+ if (!this._path.startsWith('/'))
1919
+ return;
1672
1920
  e.preventDefault();
1673
1921
  await this.router.navigate(this._path);
1674
1922
  this._updateActiveState();
@@ -1681,17 +1929,24 @@ class Link extends HTMLElement {
1681
1929
  getNavigation()?.removeEventListener('currententrychange', this._updateActiveState);
1682
1930
  window.removeEventListener('wcs:navigate', this._updateActiveState);
1683
1931
  window.removeEventListener('popstate', this._updateActiveState);
1684
- if (this._anchorElement) {
1932
+ const anchor = this._anchorElement;
1933
+ if (anchor) {
1685
1934
  if (this._onClick) {
1686
- this._anchorElement.removeEventListener('click', this._onClick);
1935
+ anchor.removeEventListener('click', this._onClick);
1687
1936
  this._onClick = undefined;
1688
1937
  }
1689
- this._anchorElement.remove();
1938
+ anchor.remove();
1690
1939
  this._anchorElement = null;
1691
1940
  }
1941
+ // anchor 配下のままだった子要素のみ取り除く(別の親に移動されていた場合に誤って strip しないため)
1692
1942
  for (const childNode of this._childNodeArray) {
1693
- childNode.parentNode?.removeChild(childNode);
1943
+ if (anchor && childNode.parentNode === anchor) {
1944
+ anchor.removeChild(childNode);
1945
+ }
1694
1946
  }
1947
+ // Router キャッシュをクリア。別の Router 配下に動的に移動された場合や
1948
+ // Router 自体が入れ替わった場合に古い参照を返さないようにする。
1949
+ this._router = null;
1695
1950
  }
1696
1951
  attributeChangedCallback(name, oldValue, newValue) {
1697
1952
  if (name === 'to' && oldValue !== newValue) {
@@ -1726,9 +1981,20 @@ class Link extends HTMLElement {
1726
1981
  const headStack = [];
1727
1982
  /**
1728
1983
  * 初期の<head>内容を記憶(最初のHead接続時に保存)
1984
+ *
1985
+ * 設計仕様: `initialHeadCaptured` は最初の Head 接続時に一度だけ true になる。
1986
+ * SPA ライフタイム中の初期 head 状態は、最初の Head が接続された瞬間がベースラインで、
1987
+ * それ以降に追加された <head> 要素は「初期値」ではなく「現在の値」として扱う。
1988
+ * テストや SPA リセットで初期値を再キャプチャしたい場合は `_resetHeadStack()` を呼ぶ。
1729
1989
  */
1730
1990
  const initialHeadValues = new Map();
1731
1991
  let initialHeadCaptured = false;
1992
+ /**
1993
+ * 要素ごとの `_getKey` 結果のキャッシュ。
1994
+ * 初期化時/キャプチャ時に算出し、以降の `_reapplyHead` ループで再計算しないようにする。
1995
+ * 要素の属性変更には追随しない(Head 内要素は初期化時に固定される前提)。
1996
+ */
1997
+ const keyCache = new WeakMap();
1732
1998
  class Head extends HTMLElement {
1733
1999
  _initialized = false;
1734
2000
  _childElementArray = [];
@@ -1774,9 +2040,21 @@ class Head extends HTMLElement {
1774
2040
  return this._childElementArray;
1775
2041
  }
1776
2042
  /**
1777
- * 要素の一意キーを生成
2043
+ * 要素の一意キーを生成(WeakMap でキャッシュ)
1778
2044
  */
1779
2045
  _getKey(el) {
2046
+ const cached = keyCache.get(el);
2047
+ if (cached !== undefined) {
2048
+ return cached;
2049
+ }
2050
+ const key = this._computeKey(el);
2051
+ keyCache.set(el, key);
2052
+ return key;
2053
+ }
2054
+ /**
2055
+ * 要素の一意キーを計算(実体)
2056
+ */
2057
+ _computeKey(el) {
1780
2058
  const tag = el.tagName.toLowerCase();
1781
2059
  if (tag === 'title') {
1782
2060
  return 'title';
@@ -1798,20 +2076,48 @@ class Head extends HTMLElement {
1798
2076
  if (tag === 'base') {
1799
2077
  return 'base';
1800
2078
  }
1801
- // script, style等はouterHTMLの先頭で識別(フォールバック)
2079
+ if (tag === 'script') {
2080
+ const src = el.getAttribute('src') || '';
2081
+ const id = el.getAttribute('id') || '';
2082
+ const type = el.getAttribute('type') || '';
2083
+ if (src || id) {
2084
+ return `script:${src}:${id}:${type}`;
2085
+ }
2086
+ // インライン script はおおまかな先頭で識別(同等性は完全一致でなく簡易判定)
2087
+ return `script::${type}:${el.outerHTML.slice(0, 100)}`;
2088
+ }
2089
+ if (tag === 'style') {
2090
+ const id = el.getAttribute('id') || '';
2091
+ const media = el.getAttribute('media') || '';
2092
+ if (id) {
2093
+ return `style:${id}:${media}`;
2094
+ }
2095
+ // インライン style はおおまかな先頭で識別(同等性は完全一致でなく簡易判定)
2096
+ return `style::${media}:${el.outerHTML.slice(0, 100)}`;
2097
+ }
2098
+ // その他要素はおおまかに識別(同等性は完全一致でなく簡易判定)
1802
2099
  return `${tag}:${el.outerHTML.slice(0, 100)}`;
1803
2100
  }
1804
2101
  /**
1805
- * head内で指定のキーに一致する要素を検索
2102
+ * head 内の要素を key で引ける Map を構築する。
2103
+ * `_reapplyHead` のループ前に一度だけ呼び出し、O(N) lookup に置き換えるためのヘルパ。
2104
+ *
2105
+ * 設計仕様: 同一 key の要素が複数 `document.head` 内に存在する場合は **first-wins**
2106
+ * (DOM 順で最初の要素のみ採用)。これは `_captureInitialHead` および
2107
+ * `initialHeadValues` の挙動とも整合する。
2108
+ * 重複は基本的にユーザーの記述ミスだが、_getKey の粒度(href/name 等の主要属性のみ)に
2109
+ * よる「論理的重複」もあり得るため、サイレントに first-wins とする。
2110
+ * 厳密な重複検出が必要な場合は呼び出し側で行う。
1806
2111
  */
1807
- _findInHead(key) {
1808
- const head = document.head;
1809
- for (const el of Array.from(head.children)) {
1810
- if (this._getKey(el) === key) {
1811
- return el;
2112
+ _buildHeadElementMap() {
2113
+ const map = new Map();
2114
+ for (const el of Array.from(document.head.children)) {
2115
+ const key = this._getKey(el);
2116
+ if (!map.has(key)) {
2117
+ map.set(key, el);
1812
2118
  }
1813
2119
  }
1814
- return null;
2120
+ return map;
1815
2121
  }
1816
2122
  /**
1817
2123
  * 初期の<head>状態をキャプチャ
@@ -1843,8 +2149,10 @@ class Head extends HTMLElement {
1843
2149
  allKeys.add(key);
1844
2150
  }
1845
2151
  // 現在のheadにある要素のキーも追加(管理下から外れたものを削除するため)
1846
- for (const child of Array.from(document.head.children)) {
1847
- allKeys.add(this._getKey(child));
2152
+ // 同時に key -> Element の lookup map も構築する(O() を避けるため)
2153
+ const headElementMap = this._buildHeadElementMap();
2154
+ for (const key of headElementMap.keys()) {
2155
+ allKeys.add(key);
1848
2156
  }
1849
2157
  // 各キーについて、最も優先度の高い値を決定
1850
2158
  for (const key of allKeys) {
@@ -1868,7 +2176,7 @@ class Head extends HTMLElement {
1868
2176
  targetElement = initial.cloneNode(true);
1869
2177
  }
1870
2178
  // headを更新
1871
- const current = this._findInHead(key);
2179
+ const current = headElementMap.get(key) ?? null;
1872
2180
  if (targetElement) {
1873
2181
  if (current) {
1874
2182
  current.replaceWith(targetElement);
@@ -1876,10 +2184,13 @@ class Head extends HTMLElement {
1876
2184
  else {
1877
2185
  document.head.appendChild(targetElement);
1878
2186
  }
2187
+ // map を新しい要素に更新(後続の同 key 処理に備える)
2188
+ headElementMap.set(key, targetElement);
1879
2189
  }
1880
2190
  else {
1881
2191
  // 初期値もスタックにもない場合は削除
1882
2192
  current?.remove();
2193
+ headElementMap.delete(key);
1883
2194
  }
1884
2195
  }
1885
2196
  }
@@ -1922,5 +2233,11 @@ function bootstrapRouter(config) {
1922
2233
  registerComponents();
1923
2234
  }
1924
2235
 
1925
- export { Route, RouteCore, Router, bootstrapRouter, getConfig };
2236
+ var version = "1.11.1";
2237
+ var pkg = {
2238
+ version: version};
2239
+
2240
+ const VERSION = pkg.version;
2241
+
2242
+ export { Route, RouteCore, Router, VERSION, bootstrapRouter, getConfig };
1926
2243
  //# sourceMappingURL=index.esm.js.map