@mandujs/core 0.39.1 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.39.1",
3
+ "version": "0.39.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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>`;
@@ -1173,6 +1173,72 @@ export function setCreateApp(fn: CreateAppFn): void {
1173
1173
  defaultRegistry.setCreateApp(fn);
1174
1174
  }
1175
1175
 
1176
+ /**
1177
+ * Issue #232 follow-up — eager page-component registration.
1178
+ *
1179
+ * After a dev-mode HMR reload, `registerPageHandler` / `registerPageLoader`
1180
+ * install lazy thunks. The actual component import and
1181
+ * `registerRouteComponent` call is deferred until the first request for
1182
+ * that route. On a brand-new browser reload triggered by HMR the request
1183
+ * normally populates the registry in time, but any code path that renders
1184
+ * without going through `loadPageData` first (or a thrown error that
1185
+ * short-circuits it) leaves `routeComponents` empty, and the default
1186
+ * appCreator renders the "404 - Route Not Found" fallback even though the
1187
+ * route is valid.
1188
+ *
1189
+ * `prewarmPageRoutes()` walks every registered `pageHandler` / `pageLoader`
1190
+ * once and drives it through the same code path the first request would
1191
+ * take, so `routeComponents` is fully populated before the server starts
1192
+ * serving. The cost is a one-time module import per page route — trivial
1193
+ * in dev. Production `mandu start` is unaffected because it already runs
1194
+ * requests through the same lazy path without HMR races.
1195
+ */
1196
+ export async function prewarmPageRoutes(
1197
+ registry: ServerRegistry = defaultRegistry,
1198
+ ): Promise<{ prewarmed: string[]; failed: Array<{ routeId: string; error: string }> }> {
1199
+ const prewarmed: string[] = [];
1200
+ const failed: Array<{ routeId: string; error: string }> = [];
1201
+
1202
+ // pageHandlers path (routes with .slot.ts / filling)
1203
+ for (const routeId of registry.pageHandlers.keys()) {
1204
+ try {
1205
+ await ensurePageRouteMetadata(routeId, registry);
1206
+ prewarmed.push(routeId);
1207
+ } catch (err) {
1208
+ failed.push({
1209
+ routeId,
1210
+ error: err instanceof Error ? err.message : String(err),
1211
+ });
1212
+ }
1213
+ }
1214
+
1215
+ // pageLoaders path (plain page components, no slot)
1216
+ for (const routeId of registry.pageLoaders.keys()) {
1217
+ if (registry.routeComponents.has(routeId)) continue; // already warm
1218
+ try {
1219
+ const loader = registry.pageLoaders.get(routeId);
1220
+ if (!loader) continue;
1221
+ const module = await loader();
1222
+ const exported: unknown = module.default;
1223
+ const exportedObj = exported as Record<string, unknown> | null;
1224
+ const component = typeof exported === "function"
1225
+ ? (exported as RouteComponent)
1226
+ : (exportedObj?.component as RouteComponent | undefined);
1227
+ if (typeof component === "function") {
1228
+ registry.registerRouteComponent(routeId, component);
1229
+ prewarmed.push(routeId);
1230
+ }
1231
+ } catch (err) {
1232
+ failed.push({
1233
+ routeId,
1234
+ error: err instanceof Error ? err.message : String(err),
1235
+ });
1236
+ }
1237
+ }
1238
+
1239
+ return { prewarmed, failed };
1240
+ }
1241
+
1176
1242
  /**
1177
1243
  * Layout 로더 등록 (전역)
1178
1244
  */
@@ -2782,6 +2848,9 @@ async function renderPageSSR(
2782
2848
  hmrPort: settings.hmrPort,
2783
2849
  routeId: route.id,
2784
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,
2785
2854
  hydration: route.hydration,
2786
2855
  bundleManifest: settings.bundleManifest,
2787
2856
  criticalData: loaderData as Record<string, unknown> | undefined,
@@ -2830,6 +2899,9 @@ async function renderPageSSR(
2830
2899
  prefetch: settings.prefetch,
2831
2900
  spa: settings.spa,
2832
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,
2833
2905
  });
2834
2906
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
2835
2907
  } catch (error) {
@@ -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
  /**