@wcstack/router 1.10.3 → 1.11.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.d.ts +38 -11
- package/dist/index.esm.js +491 -174
- package/dist/index.esm.js.map +1 -1
- package/dist/index.esm.min.js +1 -1
- package/dist/index.esm.min.js.map +1 -1
- package/package.json +3 -2
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
837
|
-
|
|
838
|
-
this.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
this.
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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, [],
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1228
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
1349
|
-
* - "/app/" -> "/app"
|
|
1350
|
-
* - "/app/index.html" -> "/app"
|
|
1565
|
+
* 共通実装は normalizePathname.ts を参照。
|
|
1351
1566
|
*/
|
|
1352
1567
|
_normalizeBasename(_path) {
|
|
1353
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
1536
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1932
|
+
const anchor = this._anchorElement;
|
|
1933
|
+
if (anchor) {
|
|
1685
1934
|
if (this._onClick) {
|
|
1686
|
-
|
|
1935
|
+
anchor.removeEventListener('click', this._onClick);
|
|
1687
1936
|
this._onClick = undefined;
|
|
1688
1937
|
}
|
|
1689
|
-
|
|
1938
|
+
anchor.remove();
|
|
1690
1939
|
this._anchorElement = null;
|
|
1691
1940
|
}
|
|
1941
|
+
// anchor 配下のままだった子要素のみ取り除く(別の親に移動されていた場合に誤って strip しないため)
|
|
1692
1942
|
for (const childNode of this._childNodeArray) {
|
|
1693
|
-
childNode.parentNode
|
|
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
|
-
|
|
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
|
-
|
|
1808
|
-
const
|
|
1809
|
-
for (const el of Array.from(head.children)) {
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
|
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
|
-
|
|
1847
|
-
|
|
2152
|
+
// 同時に key -> Element の lookup map も構築する(O(N²) を避けるため)
|
|
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 =
|
|
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
|
-
|
|
2236
|
+
var version = "1.11.0";
|
|
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
|