@mandujs/core 0.39.2 → 0.39.3
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/package.json
CHANGED
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
* flow is documented in this file's JSDoc; anyone editing this string
|
|
98
98
|
* MUST update the exclusion-matrix test and the body-swap test to match.
|
|
99
99
|
*/
|
|
100
|
-
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
100
|
+
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}try{var cRoot=document.getElementById?document.getElementById("root"):null;var nRoot=doc.getElementById?doc.getElementById("root"):null;var ck=cRoot&&cRoot.getAttribute?cRoot.getAttribute("data-mandu-layout"):"";var nk=nRoot&&nRoot.getAttribute?nRoot.getAttribute("data-mandu-layout"):"";if(ck&&nk&&ck!==nk){hardNav(url,"cross-layout transition ("+ck+" -> "+nk+")");return false;}}catch(_){}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
101
101
|
|
|
102
102
|
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
103
103
|
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
package/src/runtime/server.ts
CHANGED
|
@@ -2848,6 +2848,9 @@ async function renderPageSSR(
|
|
|
2848
2848
|
hmrPort: settings.hmrPort,
|
|
2849
2849
|
routeId: route.id,
|
|
2850
2850
|
routePattern: route.pattern,
|
|
2851
|
+
// Issue #233 — emit data-mandu-layout so SPA nav can detect
|
|
2852
|
+
// cross-layout transitions and hard-nav instead of half-swapping.
|
|
2853
|
+
layoutChain: route.layoutChain,
|
|
2851
2854
|
hydration: route.hydration,
|
|
2852
2855
|
bundleManifest: settings.bundleManifest,
|
|
2853
2856
|
criticalData: loaderData as Record<string, unknown> | undefined,
|
|
@@ -2896,6 +2899,9 @@ async function renderPageSSR(
|
|
|
2896
2899
|
prefetch: settings.prefetch,
|
|
2897
2900
|
spa: settings.spa,
|
|
2898
2901
|
devtools: settings.devtools,
|
|
2902
|
+
// Issue #233 — SPA nav uses this to detect cross-layout transitions
|
|
2903
|
+
// and fall back to a hard navigation.
|
|
2904
|
+
layoutChain: route.layoutChain,
|
|
2899
2905
|
});
|
|
2900
2906
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
2901
2907
|
} catch (error) {
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -53,6 +53,16 @@ export interface SSROptions {
|
|
|
53
53
|
bundleManifest?: BundleManifest;
|
|
54
54
|
/** 라우트 ID (island 식별용) */
|
|
55
55
|
routeId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Issue #233 — layout chain applied to this render, ordered outer → inner.
|
|
58
|
+
* Serialized into `data-mandu-layout` on `<div id="root">` so the SPA
|
|
59
|
+
* navigation helper can detect cross-layout transitions and fall back
|
|
60
|
+
* to a hard navigation instead of a soft `<main>` swap (which would
|
|
61
|
+
* leave stale layout chrome like a sidebar from the source layout).
|
|
62
|
+
* When omitted or empty the attribute is dropped — same-layout nav
|
|
63
|
+
* remains cheap and visually crossfaded.
|
|
64
|
+
*/
|
|
65
|
+
layoutChain?: string[];
|
|
56
66
|
/** 추가 head 태그 */
|
|
57
67
|
headTags?: string;
|
|
58
68
|
/** 추가 body 끝 태그 */
|
|
@@ -623,8 +633,25 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
623
633
|
spa,
|
|
624
634
|
devtools,
|
|
625
635
|
devErrorOverlay,
|
|
636
|
+
layoutChain,
|
|
626
637
|
} = options;
|
|
627
638
|
|
|
639
|
+
// Issue #233 — derive a stable short key for the layout chain so the
|
|
640
|
+
// SPA nav helper can detect cross-layout transitions. Deterministic
|
|
641
|
+
// per server, stable across the same route/layout combo. Missing /
|
|
642
|
+
// empty chain → omit the attribute entirely (attribute absence acts
|
|
643
|
+
// like a wildcard match).
|
|
644
|
+
let layoutKey = "";
|
|
645
|
+
if (layoutChain && layoutChain.length > 0) {
|
|
646
|
+
let hash = 0;
|
|
647
|
+
const joined = layoutChain.join("|");
|
|
648
|
+
for (let i = 0; i < joined.length; i++) {
|
|
649
|
+
hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
|
|
650
|
+
}
|
|
651
|
+
layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
|
|
652
|
+
}
|
|
653
|
+
const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
|
|
654
|
+
|
|
628
655
|
// CSS 링크 태그 생성
|
|
629
656
|
// - cssPath가 string이면 해당 경로 사용
|
|
630
657
|
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
@@ -804,7 +831,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
804
831
|
${devErrorOverlayTag}
|
|
805
832
|
</head>
|
|
806
833
|
<body>
|
|
807
|
-
<div id="root">${bodyContent}</div>
|
|
834
|
+
<div id="root"${rootAttrs}>${bodyContent}</div>
|
|
808
835
|
${dataScript}
|
|
809
836
|
${routeScript}
|
|
810
837
|
${hydrationScripts}
|
|
@@ -99,6 +99,12 @@ export interface StreamingSSROptions {
|
|
|
99
99
|
routeId?: string;
|
|
100
100
|
/** 라우트 패턴 */
|
|
101
101
|
routePattern?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Issue #233 — layout chain (outer → inner). Emitted as
|
|
104
|
+
* `data-mandu-layout="<hash>"` on `<div id="root">` so the SPA
|
|
105
|
+
* navigation helper can detect cross-layout transitions.
|
|
106
|
+
*/
|
|
107
|
+
layoutChain?: string[];
|
|
102
108
|
/** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
|
|
103
109
|
criticalData?: Record<string, unknown>;
|
|
104
110
|
// Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
|
|
@@ -543,8 +549,24 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
543
549
|
transitions = true,
|
|
544
550
|
prefetch = true,
|
|
545
551
|
spa = true,
|
|
552
|
+
layoutChain,
|
|
546
553
|
} = options;
|
|
547
554
|
|
|
555
|
+
// Issue #233 — layout-key for SPA cross-layout detection. Mirror of the
|
|
556
|
+
// block in `ssr.ts::renderToHTML`; see that call-site for the full
|
|
557
|
+
// rationale. Streaming path must stamp the same attribute so the SPA
|
|
558
|
+
// helper's layout-mismatch heuristic is consistent across modes.
|
|
559
|
+
let layoutKey = "";
|
|
560
|
+
if (layoutChain && layoutChain.length > 0) {
|
|
561
|
+
let hash = 0;
|
|
562
|
+
const joined = layoutChain.join("|");
|
|
563
|
+
for (let i = 0; i < joined.length; i++) {
|
|
564
|
+
hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
|
|
565
|
+
}
|
|
566
|
+
layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
|
|
567
|
+
}
|
|
568
|
+
const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
|
|
569
|
+
|
|
548
570
|
// CSS 링크 태그 생성
|
|
549
571
|
// - cssPath가 string이면 해당 경로 사용
|
|
550
572
|
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
@@ -652,7 +674,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
652
674
|
${fastRefreshPreamble}
|
|
653
675
|
</head>
|
|
654
676
|
<body>
|
|
655
|
-
<div id="root">${islandOpenTag}`;
|
|
677
|
+
<div id="root"${rootAttrs}>${islandOpenTag}`;
|
|
656
678
|
}
|
|
657
679
|
|
|
658
680
|
/**
|