@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 +1 -1
- package/src/client/spa-nav-helper.ts +1 -1
- package/src/runtime/server.ts +72 -0
- package/src/runtime/ssr.ts +28 -1
- package/src/runtime/streaming-ssr.ts +23 -1
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
|
@@ -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) {
|
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
|
/**
|