@jasonshimmy/vite-plugin-cer-app 0.20.4 → 0.20.5
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/CHANGELOG.md +4 -0
- package/commits.txt +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +11 -0
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -1
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +48 -21
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/use-page-data.d.ts +0 -41
- package/dist/runtime/composables/use-page-data.d.ts.map +1 -1
- package/dist/runtime/composables/use-page-data.js +44 -20
- package/dist/runtime/composables/use-page-data.js.map +1 -1
- package/dist/runtime/entry-client-template.d.ts +1 -1
- package/dist/runtime/entry-client-template.d.ts.map +1 -1
- package/dist/runtime/entry-client-template.js +10 -0
- package/dist/runtime/entry-client-template.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +22 -7
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/e2e/cypress/e2e/group-meta.cy.ts +5 -2
- package/package.json +1 -1
- package/src/__tests__/plugin/entry-server-template.test.ts +7 -2
- package/src/__tests__/plugin/virtual/routes.test.ts +26 -0
- package/src/__tests__/runtime/app-template.test.ts +25 -13
- package/src/__tests__/runtime/entry-client-template.test.ts +7 -1
- package/src/__tests__/runtime/use-page-data.test.ts +178 -1
- package/src/plugin/virtual/routes.ts +11 -0
- package/src/runtime/app-template.ts +48 -21
- package/src/runtime/composables/use-page-data.ts +50 -20
- package/src/runtime/entry-client-template.ts +10 -0
- package/src/runtime/entry-server-template.ts +22 -7
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
* - useHead() support via beginHeadCollection / endHeadCollection
|
|
12
12
|
* - DSD polyfill injected at end of <body> after client-template merge
|
|
13
13
|
*/
|
|
14
|
-
export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport serverMiddleware from 'virtual:cer-server-middleware'\nimport { runtimeConfig, _runtimePrivateDefaults, _authSessionKey, _hooks } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig, useSession } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\n\n// Resolve private config from environment variables at server startup.\n// Each key declared in runtimeConfig.private is looked up in process.env,\n// first as-is, then as ALL_CAPS. The declared default is used as fallback.\ninitRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })\n\n// Pre-load the full HTML entity map so named entities like — decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (<, >, & \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Async-local storage for request-scoped req/res access.\n// Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers\n// without prop-drilling the request context through the component tree.\nconst _cerReqStore = new AsyncLocalStorage()\n;(globalThis).__CER_REQ_STORE__ = _cerReqStore\n\n// Async-local storage for the authenticated user resolved before each render.\n// useAuth() reads this store server-side to return the current user synchronously.\nconst _cerAuthStore = new AsyncLocalStorage()\n;(globalThis).__CER_AUTH_STORE__ = _cerAuthStore\n\n// Async-local storage for per-request useFetch() data.\n// useFetch() writes fetched results here; the handler serialises the map into\n// window.__CER_FETCH_DATA__ for client-side hydration.\nconst _cerFetchStore = new AsyncLocalStorage()\n;(globalThis).__CER_FETCH_STORE__ = _cerFetchStore\n\n// Async-local storage for the current route info (path, params, query, meta).\n// useRoute() reads this on the server so layouts and components can access\n// route metadata without prop-drilling.\nconst _cerRouteStore = new AsyncLocalStorage()\n;(globalThis).__CER_ROUTE_STORE__ = _cerRouteStore\n\n// Async-local storage for per-request useState() reactive state.\n// Each request gets a fresh Map so concurrent SSR/SSG renders never share\n// reactive state set by different pages or loaders.\nconst _cerStateStore = new AsyncLocalStorage()\n;(globalThis).__CER_STATE_STORE__ = _cerStateStore\n\n// Runs fn inside the per-request AsyncLocalStorage context so that isomorphic\n// composables (useCookie, useSession, etc.) can access req/res without prop-drilling.\n// Call this for every API handler invocation \u2014 not just SSR renders.\nexport function runWithRequestContext(req, res, fn) {\n return _cerReqStore.run({ req, res }, fn)\n}\n\n// Runs the server/middleware/ chain for a request.\n// Returns false if a middleware short-circuited the response; true to continue.\n// Exported so Netlify / Vercel / Cloudflare bridges can also call it for API routes.\n// P1-2: Middleware may throw { status: 401 } (or any numeric .status) to produce\n// a non-500 response \u2014 the status is extracted and forwarded to res.statusCode.\nexport async function runServerMiddleware(req, res) {\n for (const { handler: mw } of (serverMiddleware ?? [])) {\n if (typeof mw !== 'function') continue\n let calledNext = false\n try {\n await new Promise((resolve, reject) => {\n Promise.resolve(mw(req, res, (err) => {\n if (err) reject(err)\n else { calledNext = true; resolve() }\n })).catch(reject)\n })\n } catch (err) {\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'middleware', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n if (!res.writableEnded) {\n const statusCode = (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number')\n ? (isNaN(err.status) ? 500 : err.status)\n : 500\n res.statusCode = statusCode\n res.end('Internal Server Error')\n }\n return false\n }\n if (res.writableEnded || !calledNext) return false\n }\n return true\n}\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\n//\n// Cloudflare Workers (and other runtimes without node:fs) can inject the\n// template before this module loads by setting globalThis.__CER_CLIENT_TEMPLATE__.\n// The Cloudflare adapter inlines dist/client/index.html as a string constant in\n// _worker.js and sets the global before dynamically importing the server bundle.\nlet _clientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? null\nif (!_clientTemplate) {\n try {\n const _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\n _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n } catch {\n // node:fs not available in this runtime \u2014 Cloudflare adapter must set\n // globalThis.__CER_CLIENT_TEMPLATE__ before importing this bundle.\n }\n}\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Store the current route info so useRoute() can read it synchronously\n // from any layout or component during this render pass.\n _cerRouteStore.enterWith({\n path: current.path,\n params,\n query: current.query ?? {},\n meta: route?.meta ?? null,\n })\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n // P2-2: Route-level error tag (from co-located .error.ts or _error.ts).\n // Preferred over the global errorTag when rendering loader errors for this route.\n const routeErrorTag = mod.errorTag ?? null\n\n // P1-1: Synthetic 404 catch-all \u2014 no page component registered.\n if (!pageTag) {\n const notFoundErrorTag = routeErrorTag ?? errorTag\n const notFoundVnode = notFoundErrorTag\n ? { tag: notFoundErrorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: notFoundVnode, router, head: undefined, status: 404 }\n }\n\n // Run the loader before creating the page vnode so we can pass its\n // primitive return values as HTML attributes. useProps() in the page\n // component reads element attributes, so merging loader data here makes\n // both useProps() and usePageData() work in SSR / SSG.\n let loaderAttrs = {}\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n // Expose primitive loader values as element attributes so useProps()\n // can read them. Complex objects are only accessible via usePageData().\n loaderAttrs = Object.fromEntries(\n Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')\n )\n }\n }\n\n pageVnode = { tag: pageTag, props: { attrs: { ...params, ...loaderAttrs } }, children: [] }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'loader', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n // P2-2: Prefer the route-level errorTag over the global one.\n // routeErrorTag is not in scope here; use route?.meta?.errorTag from the matched route.\n const effectiveErrorTag = route?.meta?.errorTag ?? errorTag\n if (!effectiveErrorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = effectiveErrorTag\n ? { tag: effectiveErrorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n // If the request matched a catch-all route (user-defined 404.ts or [...all].ts),\n // return HTTP 404 so browsers and crawlers treat it as a not-found response.\n const isCatchAll = route?.path === '/:all*'\n return { vnode, router, head, status: isCatchAll ? 404 : null }\n}\n\nexport const handler = async (req, res) => {\n const _requestPath = new URL(req.url ?? '/', 'http://x').pathname\n const _requestStart = Date.now()\n if (_hooks?.onRequest) {\n try { await _hooks.onRequest({ path: _requestPath, method: req.method ?? 'GET', req }) } catch { /* hooks must not crash the handler */ }\n }\n await _cerStateStore.run(new Map(), async () => {\n await _cerReqStore.run({ req, res }, async () => {\n await _cerDataStore.run(null, async () => {\n // Fresh per-request fetch map \u2014 populated by useFetch() calls inside loaders.\n const _fetchMap = new Map()\n await _cerFetchStore.run(_fetchMap, async () => {\n // Pre-resolve the authenticated user so useAuth() works synchronously during rendering.\n let _authUser = null\n if (_authSessionKey) {\n try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }\n }\n await _cerAuthStore.run(_authUser, async () => {\n const { vnode, router, head, status } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n let _headCollectionOpen = false\n try {\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n _headCollectionOpen = true\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n _headCollectionOpen = false\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk \u2014 rejects if the sync render failed.\n const { value: firstChunk = '' } = await reader.read()\n\n // Serialise useFetch() results collected during loader execution.\n const _fetchObj = Object.fromEntries(_fetchMap)\n const _fetchScript = Object.keys(_fetchObj).length > 0\n ? `<script>window.__CER_FETCH_DATA__ = ${JSON.stringify(_fetchObj)}</script>`\n : ''\n\n // Serialise the auth user for client-side hydration via useAuth().\n const _authScript = _authUser\n ? `<script>window.__CER_AUTH_USER__ = ${JSON.stringify(_authUser)}</script>`\n : ''\n\n // Serialise useState() values for client-side hydration.\n // The state Map is populated by loader calls (in _prepareRequest) and by component\n // render functions (during renderToStreamWithJITCSSDSD above). Both run before this\n // point. Injected as window.__CER_STATE_INIT__ so the client useState() can\n // pre-populate its singleton Map on first use \u2014 no flash to default values.\n const _stateMap = _cerStateStore.getStore()\n let _stateScript = ''\n if (_stateMap && _stateMap.size > 0) {\n const _stateObj = {}\n for (const [k, v] of _stateMap) { _stateObj[k] = v.value }\n _stateScript = `<script>window.__CER_STATE_INIT__ = ${JSON.stringify(_stateObj)}</script>`\n }\n\n // Merge loader data script + useHead() tags + fetch/auth/state hydration scripts.\n const headContent = [head, headTags, _fetchScript, _authScript, _stateScript].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n // In dev mode the module-level _clientTemplate is null (only the\n // production dist/client/index.html path is searched at init time).\n // The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request\n // after running server.transformIndexHtml so the Vite client scripts\n // (/@vite/client, HMR) are included in every SSR response.\n const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate\n const merged = _resolvedClientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n } catch (_renderErr) {\n if (_hooks?.onError) {\n try { await _hooks.onError(_renderErr, { type: 'render', path: _requestPath, req }) } catch { /* hooks must not crash the handler */ }\n }\n // Ensure the head collector is never left open on error.\n if (_headCollectionOpen) { try { endHeadCollection() } catch { /* ignore */ } }\n // If headers have not been flushed yet we can still send a proper 500 page.\n // If writing has already started we can only close the connection cleanly.\n if (!res.headersSent) {\n res.statusCode = 500\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end('<!DOCTYPE html><html><head></head><body><h1>500 Internal Server Error</h1><p>An unexpected error occurred while rendering this page.</p></body></html>')\n } else {\n res.end()\n }\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n }\n }) // _cerAuthStore.run\n }) // _cerFetchStore.run\n }) // _cerDataStore.run\n }) // _cerReqStore.run\n }) // _cerStateStore.run\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes, serverMiddleware }\nexport default handler\n";
|
|
14
|
+
export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport serverMiddleware from 'virtual:cer-server-middleware'\nimport { runtimeConfig, _runtimePrivateDefaults, _authSessionKey, _hooks } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig, useSession } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\n\n// Resolve private config from environment variables at server startup.\n// Each key declared in runtimeConfig.private is looked up in process.env,\n// first as-is, then as ALL_CAPS. The declared default is used as fallback.\ninitRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })\n\n// Pre-load the full HTML entity map so named entities like — decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (<, >, & \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Async-local storage for request-scoped req/res access.\n// Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers\n// without prop-drilling the request context through the component tree.\nconst _cerReqStore = new AsyncLocalStorage()\n;(globalThis).__CER_REQ_STORE__ = _cerReqStore\n\n// Async-local storage for the authenticated user resolved before each render.\n// useAuth() reads this store server-side to return the current user synchronously.\nconst _cerAuthStore = new AsyncLocalStorage()\n;(globalThis).__CER_AUTH_STORE__ = _cerAuthStore\n\n// Async-local storage for per-request useFetch() data.\n// useFetch() writes fetched results here; the handler serialises the map into\n// window.__CER_FETCH_DATA__ for client-side hydration.\nconst _cerFetchStore = new AsyncLocalStorage()\n;(globalThis).__CER_FETCH_STORE__ = _cerFetchStore\n\n// Async-local storage for the current route info (path, params, query, meta).\n// useRoute() reads this on the server so layouts and components can access\n// route metadata without prop-drilling.\nconst _cerRouteStore = new AsyncLocalStorage()\n;(globalThis).__CER_ROUTE_STORE__ = _cerRouteStore\n\n// Async-local storage for per-request useState() reactive state.\n// Each request gets a fresh Map so concurrent SSR/SSG renders never share\n// reactive state set by different pages or loaders.\nconst _cerStateStore = new AsyncLocalStorage()\n;(globalThis).__CER_STATE_STORE__ = _cerStateStore\n\n// Runs fn inside the per-request AsyncLocalStorage context so that isomorphic\n// composables (useCookie, useSession, etc.) can access req/res without prop-drilling.\n// Call this for every API handler invocation \u2014 not just SSR renders.\nexport function runWithRequestContext(req, res, fn) {\n return _cerReqStore.run({ req, res }, fn)\n}\n\n// Runs the server/middleware/ chain for a request.\n// Returns false if a middleware short-circuited the response; true to continue.\n// Exported so Netlify / Vercel / Cloudflare bridges can also call it for API routes.\n// P1-2: Middleware may throw { status: 401 } (or any numeric .status) to produce\n// a non-500 response \u2014 the status is extracted and forwarded to res.statusCode.\nexport async function runServerMiddleware(req, res) {\n for (const { handler: mw } of (serverMiddleware ?? [])) {\n if (typeof mw !== 'function') continue\n let calledNext = false\n try {\n await new Promise((resolve, reject) => {\n Promise.resolve(mw(req, res, (err) => {\n if (err) reject(err)\n else { calledNext = true; resolve() }\n })).catch(reject)\n })\n } catch (err) {\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'middleware', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n if (!res.writableEnded) {\n const statusCode = (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number')\n ? (isNaN(err.status) ? 500 : err.status)\n : 500\n res.statusCode = statusCode\n res.end('Internal Server Error')\n }\n return false\n }\n if (res.writableEnded || !calledNext) return false\n }\n return true\n}\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\n//\n// Cloudflare Workers (and other runtimes without node:fs) can inject the\n// template before this module loads by setting globalThis.__CER_CLIENT_TEMPLATE__.\n// The Cloudflare adapter inlines dist/client/index.html as a string constant in\n// _worker.js and sets the global before dynamically importing the server bundle.\nlet _clientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? null\nif (!_clientTemplate) {\n try {\n const _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\n _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n } catch {\n // node:fs not available in this runtime \u2014 Cloudflare adapter must set\n // globalThis.__CER_CLIENT_TEMPLATE__ before importing this bundle.\n }\n}\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is returned so the handler can scope it to _cerDataStore.run()\n// during rendering. (AsyncLocalStorage.enterWith() inside an awaited child\n// function does not propagate back to the parent continuation, so run() is\n// the only reliable approach.)\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Store the current route info so useRoute() can read it synchronously\n // from any layout or component during this render pass.\n _cerRouteStore.enterWith({\n path: current.path,\n params,\n query: current.query ?? {},\n meta: route?.meta ?? null,\n })\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n // Loader data to pass to usePageData() during rendering. Declared here\n // (outside try/catch) so it's visible in all return paths.\n let loaderData = null\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n // P2-2: Route-level error tag (from co-located .error.ts or _error.ts).\n // Preferred over the global errorTag when rendering loader errors for this route.\n const routeErrorTag = mod.errorTag ?? null\n\n // P1-1: Synthetic 404 catch-all \u2014 no page component registered.\n if (!pageTag) {\n const notFoundErrorTag = routeErrorTag ?? errorTag\n const notFoundVnode = notFoundErrorTag\n ? { tag: notFoundErrorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: notFoundVnode, router, head: undefined, status: 404 }\n }\n\n // Run the loader before creating the page vnode so we can pass its\n // primitive return values as HTML attributes. useProps() in the page\n // component reads element attributes, so merging loader data here makes\n // both useProps() and usePageData() work in SSR / SSG.\n let loaderAttrs = {}\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // Store loader data so the handler can pass it to _cerDataStore.run()\n // below. Using enterWith() here doesn't work because it only modifies\n // the async context *inside* this awaited function, not the outer\n // handler's continuation where renderToStreamWithJITCSSDSD runs.\n loaderData = data\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n // Expose primitive loader values as element attributes so useProps()\n // can read them. Complex objects are only accessible via usePageData().\n loaderAttrs = Object.fromEntries(\n Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')\n )\n }\n }\n\n pageVnode = { tag: pageTag, props: { attrs: { ...params, ...loaderAttrs } }, children: [] }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'loader', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n // P2-2: Prefer the route-level errorTag over the global one.\n // routeErrorTag is not in scope here; use route?.meta?.errorTag from the matched route.\n const effectiveErrorTag = route?.meta?.errorTag ?? errorTag\n if (!effectiveErrorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = effectiveErrorTag\n ? { tag: effectiveErrorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n // If the request matched a catch-all route (user-defined 404.ts or [...all].ts),\n // return HTTP 404 so browsers and crawlers treat it as a not-found response.\n const isCatchAll = route?.path === '/:all*'\n return { vnode, router, head, status: isCatchAll ? 404 : null, loaderData }\n}\n\nexport const handler = async (req, res) => {\n const _requestPath = new URL(req.url ?? '/', 'http://x').pathname\n const _requestStart = Date.now()\n if (_hooks?.onRequest) {\n try { await _hooks.onRequest({ path: _requestPath, method: req.method ?? 'GET', req }) } catch { /* hooks must not crash the handler */ }\n }\n await _cerStateStore.run(new Map(), async () => {\n await _cerReqStore.run({ req, res }, async () => {\n await _cerDataStore.run(null, async () => {\n // Fresh per-request fetch map \u2014 populated by useFetch() calls inside loaders.\n const _fetchMap = new Map()\n await _cerFetchStore.run(_fetchMap, async () => {\n // Pre-resolve the authenticated user so useAuth() works synchronously during rendering.\n let _authUser = null\n if (_authSessionKey) {\n try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }\n }\n await _cerAuthStore.run(_authUser, async () => {\n const { vnode, router, head, status, loaderData } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n let _headCollectionOpen = false\n // Wrap the entire render pass in _cerDataStore.run(loaderData) so that\n // usePageData() inside component renderFn calls sees the correct store\n // value. AsyncLocalStorage.enterWith() inside _prepareRequest does NOT\n // propagate back to this outer async continuation \u2014 it only affects the\n // async chain inside _prepareRequest itself. Using run() here is the only\n // reliable way to scope the data store to the synchronous render pass.\n await _cerDataStore.run(loaderData ?? null, async () => {\n try {\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n _headCollectionOpen = true\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n _headCollectionOpen = false\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk \u2014 rejects if the sync render failed.\n const { value: firstChunk = '' } = await reader.read()\n\n // Serialise useFetch() results collected during loader execution.\n const _fetchObj = Object.fromEntries(_fetchMap)\n const _fetchScript = Object.keys(_fetchObj).length > 0\n ? `<script>window.__CER_FETCH_DATA__ = ${JSON.stringify(_fetchObj)}</script>`\n : ''\n\n // Serialise the auth user for client-side hydration via useAuth().\n const _authScript = _authUser\n ? `<script>window.__CER_AUTH_USER__ = ${JSON.stringify(_authUser)}</script>`\n : ''\n\n // Serialise useState() values for client-side hydration.\n // The state Map is populated by loader calls (in _prepareRequest) and by component\n // render functions (during renderToStreamWithJITCSSDSD above). Both run before this\n // point. Injected as window.__CER_STATE_INIT__ so the client useState() can\n // pre-populate its singleton Map on first use \u2014 no flash to default values.\n const _stateMap = _cerStateStore.getStore()\n let _stateScript = ''\n if (_stateMap && _stateMap.size > 0) {\n const _stateObj = {}\n for (const [k, v] of _stateMap) { _stateObj[k] = v.value }\n _stateScript = `<script>window.__CER_STATE_INIT__ = ${JSON.stringify(_stateObj)}</script>`\n }\n\n // Merge loader data script + useHead() tags + fetch/auth/state hydration scripts.\n const headContent = [head, headTags, _fetchScript, _authScript, _stateScript].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n // In dev mode the module-level _clientTemplate is null (only the\n // production dist/client/index.html path is searched at init time).\n // The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request\n // after running server.transformIndexHtml so the Vite client scripts\n // (/@vite/client, HMR) are included in every SSR response.\n const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate\n const merged = _resolvedClientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n } catch (_renderErr) {\n if (_hooks?.onError) {\n try { await _hooks.onError(_renderErr, { type: 'render', path: _requestPath, req }) } catch { /* hooks must not crash the handler */ }\n }\n // Ensure the head collector is never left open on error.\n if (_headCollectionOpen) { try { endHeadCollection() } catch { /* ignore */ } }\n // If headers have not been flushed yet we can still send a proper 500 page.\n // If writing has already started we can only close the connection cleanly.\n if (!res.headersSent) {\n res.statusCode = 500\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end('<!DOCTYPE html><html><head></head><body><h1>500 Internal Server Error</h1><p>An unexpected error occurred while rendering this page.</p></body></html>')\n } else {\n res.end()\n }\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n }\n }) // _cerDataStore.run(loaderData)\n }) // _cerAuthStore.run\n }) // _cerFetchStore.run\n }) // _cerDataStore.run\n }) // _cerReqStore.run\n }) // _cerStateStore.run\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes, serverMiddleware }\nexport default handler\n";
|
|
15
15
|
//# sourceMappingURL=entry-server-template.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,
|
|
1
|
+
{"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,wzuBA8cjC,CAAA"}
|
|
@@ -214,8 +214,10 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
214
214
|
|
|
215
215
|
// Per-request async setup: initialize a fresh router, resolve the matched
|
|
216
216
|
// route and layout, pre-load the page module, and call the data loader.
|
|
217
|
-
// Loader data is
|
|
218
|
-
//
|
|
217
|
+
// Loader data is returned so the handler can scope it to _cerDataStore.run()
|
|
218
|
+
// during rendering. (AsyncLocalStorage.enterWith() inside an awaited child
|
|
219
|
+
// function does not propagate back to the parent continuation, so run() is
|
|
220
|
+
// the only reliable approach.)
|
|
219
221
|
const _prepareRequest = async (req) => {
|
|
220
222
|
await _pluginsReady
|
|
221
223
|
const router = initRouter({ routes, initialUrl: req.url ?? '/' })
|
|
@@ -236,6 +238,9 @@ const _prepareRequest = async (req) => {
|
|
|
236
238
|
// and breaks Declarative Shadow DOM on initial parse).
|
|
237
239
|
let pageVnode = { tag: 'div', props: {}, children: [] }
|
|
238
240
|
let head
|
|
241
|
+
// Loader data to pass to usePageData() during rendering. Declared here
|
|
242
|
+
// (outside try/catch) so it's visible in all return paths.
|
|
243
|
+
let loaderData = null
|
|
239
244
|
if (route?.load) {
|
|
240
245
|
try {
|
|
241
246
|
const mod = await route.load()
|
|
@@ -262,9 +267,11 @@ const _prepareRequest = async (req) => {
|
|
|
262
267
|
const query = current.query ?? {}
|
|
263
268
|
const data = await mod.loader({ params, query, req })
|
|
264
269
|
if (data !== undefined && data !== null) {
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
270
|
+
// Store loader data so the handler can pass it to _cerDataStore.run()
|
|
271
|
+
// below. Using enterWith() here doesn't work because it only modifies
|
|
272
|
+
// the async context *inside* this awaited function, not the outer
|
|
273
|
+
// handler's continuation where renderToStreamWithJITCSSDSD runs.
|
|
274
|
+
loaderData = data
|
|
268
275
|
head = \`<script>window.__CER_DATA__ = \${JSON.stringify(data)}</script>\`
|
|
269
276
|
// Expose primitive loader values as element attributes so useProps()
|
|
270
277
|
// can read them. Complex objects are only accessible via usePageData().
|
|
@@ -311,7 +318,7 @@ const _prepareRequest = async (req) => {
|
|
|
311
318
|
// If the request matched a catch-all route (user-defined 404.ts or [...all].ts),
|
|
312
319
|
// return HTTP 404 so browsers and crawlers treat it as a not-found response.
|
|
313
320
|
const isCatchAll = route?.path === '/:all*'
|
|
314
|
-
return { vnode, router, head, status: isCatchAll ? 404 : null }
|
|
321
|
+
return { vnode, router, head, status: isCatchAll ? 404 : null, loaderData }
|
|
315
322
|
}
|
|
316
323
|
|
|
317
324
|
export const handler = async (req, res) => {
|
|
@@ -332,10 +339,17 @@ export const handler = async (req, res) => {
|
|
|
332
339
|
try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }
|
|
333
340
|
}
|
|
334
341
|
await _cerAuthStore.run(_authUser, async () => {
|
|
335
|
-
const { vnode, router, head, status } = await _prepareRequest(req)
|
|
342
|
+
const { vnode, router, head, status, loaderData } = await _prepareRequest(req)
|
|
336
343
|
if (status != null) res.statusCode = status
|
|
337
344
|
|
|
338
345
|
let _headCollectionOpen = false
|
|
346
|
+
// Wrap the entire render pass in _cerDataStore.run(loaderData) so that
|
|
347
|
+
// usePageData() inside component renderFn calls sees the correct store
|
|
348
|
+
// value. AsyncLocalStorage.enterWith() inside _prepareRequest does NOT
|
|
349
|
+
// propagate back to this outer async continuation — it only affects the
|
|
350
|
+
// async chain inside _prepareRequest itself. Using run() here is the only
|
|
351
|
+
// reliable way to scope the data store to the synchronous render pass.
|
|
352
|
+
await _cerDataStore.run(loaderData ?? null, async () => {
|
|
339
353
|
try {
|
|
340
354
|
// Begin collecting useHead() calls made during the synchronous render pass.
|
|
341
355
|
// IMPORTANT: the stream's start() function runs synchronously on construction,
|
|
@@ -445,6 +459,7 @@ export const handler = async (req, res) => {
|
|
|
445
459
|
try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }
|
|
446
460
|
}
|
|
447
461
|
}
|
|
462
|
+
}) // _cerDataStore.run(loaderData)
|
|
448
463
|
}) // _cerAuthStore.run
|
|
449
464
|
}) // _cerFetchStore.run
|
|
450
465
|
}) // _cerDataStore.run
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG
|
|
1
|
+
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8cpC,CAAA"}
|
|
@@ -25,8 +25,11 @@ describe('Group meta from _layout.ts (P2-1)', () => {
|
|
|
25
25
|
|
|
26
26
|
context('layout inheritance', () => {
|
|
27
27
|
it('pages in a group directory use the layout declared in _layout.ts', () => {
|
|
28
|
-
|
|
29
|
-
//
|
|
28
|
+
// Use the page that overrides the blocking group middleware so this
|
|
29
|
+
// assertion isolates layout inheritance from the redirect behavior.
|
|
30
|
+
cy.visit('/group-meta-test/custom-mw')
|
|
31
|
+
cy.get('[data-cy=custom-mw-marker]', { timeout: 5000 }).should('exist')
|
|
32
|
+
// The group layout still wraps the page.
|
|
30
33
|
cy.get('[data-cy=group-layout]', { timeout: 5000 }).should('exist')
|
|
31
34
|
})
|
|
32
35
|
})
|
package/package.json
CHANGED
|
@@ -62,8 +62,13 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
62
62
|
expect(src).toContain('_cerDataStore.run(')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('
|
|
66
|
-
|
|
65
|
+
it('scopes loader data in _cerDataStore.run(loaderData) for rendering', () => {
|
|
66
|
+
// enterWith() does not propagate across await boundaries to a parent
|
|
67
|
+
// async continuation; run() is used instead so usePageData() in
|
|
68
|
+
// renderToStreamWithJITCSSDSD sees the correct store value.
|
|
69
|
+
expect(src).toContain('_cerDataStore.run(loaderData')
|
|
70
|
+
expect(src).toContain('loaderData = data')
|
|
71
|
+
expect(src).not.toContain('_cerDataStore.enterWith(data)')
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
it('initializes plugins and sets globalThis.__cerPluginProvides', () => {
|
|
@@ -738,6 +738,32 @@ describe('generateRoutesCode — synthetic 404 catch-all (P1-1)', () => {
|
|
|
738
738
|
})
|
|
739
739
|
})
|
|
740
740
|
|
|
741
|
+
// ─── hydration middleware replay skip ───────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
describe('generateRoutesCode — hydration middleware replay skip', () => {
|
|
744
|
+
beforeEach(() => {
|
|
745
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
746
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
747
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('emits a one-shot hydration replay skip before importing middleware', async () => {
|
|
751
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
752
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
753
|
+
`export const meta = { middleware: ['auth'] }` as never,
|
|
754
|
+
)
|
|
755
|
+
const code = await generateRoutesCode(PAGES)
|
|
756
|
+
expect(code).toContain(`const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
757
|
+
expect(code).toContain(`from.path === to.path`)
|
|
758
|
+
expect(code).toContain(`_hydrationEntry.path === to.path`)
|
|
759
|
+
expect(code).toContain(`delete (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
760
|
+
const hydrationIdx = code.indexOf(`const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
761
|
+
const importIdx = code.indexOf(`const { middleware } = await import('virtual:cer-middleware')`)
|
|
762
|
+
expect(hydrationIdx).toBeGreaterThanOrEqual(0)
|
|
763
|
+
expect(importIdx).toBeGreaterThan(hydrationIdx)
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
|
|
741
767
|
// ─── P2-1: _layout.ts group meta inheritance ─────────────────────────────────
|
|
742
768
|
|
|
743
769
|
describe('generateRoutesCode — group meta from _layout.ts (P2-1)', () => {
|
|
@@ -55,10 +55,17 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
|
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
it('_doHydrate pre-loads the page and calls _replace', () => {
|
|
58
|
-
expect(APP_ENTRY_TEMPLATE).toContain('await _loadPageForPath(
|
|
58
|
+
expect(APP_ENTRY_TEMPLATE).toContain('await _loadPageForPath(')
|
|
59
|
+
expect(APP_ENTRY_TEMPLATE).toContain('_initPath,')
|
|
59
60
|
expect(APP_ENTRY_TEMPLATE).toContain('await _replace(_initPath)')
|
|
60
61
|
})
|
|
61
62
|
|
|
63
|
+
it('_doHydrate reuses existing SSR loader data instead of re-running the initial loader', () => {
|
|
64
|
+
expect(APP_ENTRY_TEMPLATE).toContain("Object.prototype.hasOwnProperty.call(globalThis, '__CER_DATA__')")
|
|
65
|
+
expect(APP_ENTRY_TEMPLATE).toContain('runLoader: false')
|
|
66
|
+
expect(APP_ENTRY_TEMPLATE).toContain('initialData: (globalThis).__CER_DATA__')
|
|
67
|
+
})
|
|
68
|
+
|
|
62
69
|
it('_doHydrate skips _replace if URL changed during async module load', () => {
|
|
63
70
|
// Guard: only call _replace when the URL hasn't changed during _loadPageForPath.
|
|
64
71
|
// This prevents _doHydrate from overriding a navigation that fired while the
|
|
@@ -75,25 +82,25 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
|
|
|
75
82
|
expect(APP_ENTRY_TEMPLATE).toContain('_currentPagePath === current.value.path')
|
|
76
83
|
})
|
|
77
84
|
|
|
85
|
+
it('keeps the SSR slot during the router subscribe initial state push', () => {
|
|
86
|
+
expect(APP_ENTRY_TEMPLATE).toContain('let _sawInitialRouteState = false')
|
|
87
|
+
expect(APP_ENTRY_TEMPLATE).toContain('const _isInitialSubscribePush = !_sawInitialRouteState')
|
|
88
|
+
expect(APP_ENTRY_TEMPLATE).toContain('if (_isInitialSubscribePush) {')
|
|
89
|
+
expect(APP_ENTRY_TEMPLATE).toContain('return')
|
|
90
|
+
})
|
|
91
|
+
|
|
78
92
|
it('exposes router globally as __cerRouter', () => {
|
|
79
93
|
expect(APP_ENTRY_TEMPLATE).toContain('__cerRouter')
|
|
80
94
|
})
|
|
81
95
|
|
|
82
|
-
it('_doHydrate
|
|
83
|
-
// The
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// data before the scheduled render can read it, causing usePageData() to
|
|
87
|
-
// always return null on initial SSR/SSG page load.
|
|
96
|
+
it('_doHydrate keeps initial __CER_DATA__ available after hydration', () => {
|
|
97
|
+
// The initial page data must survive hydration because some browsers may
|
|
98
|
+
// perform a later upgrade/re-render of the hydrated page component. The
|
|
99
|
+
// next real client navigation clears __CER_DATA__ before loading new data.
|
|
88
100
|
const doHydrateStart = APP_ENTRY_TEMPLATE.indexOf('const _doHydrate')
|
|
89
101
|
const doHydrateEnd = APP_ENTRY_TEMPLATE.indexOf('\n }', doHydrateStart)
|
|
90
102
|
const doHydrateBlock = APP_ENTRY_TEMPLATE.slice(doHydrateStart, doHydrateEnd)
|
|
91
|
-
expect(doHydrateBlock).toContain('queueMicrotask')
|
|
92
|
-
expect(doHydrateBlock).toContain('delete (globalThis).__CER_DATA__')
|
|
93
|
-
// The delete must be INSIDE a queueMicrotask callback, not inline
|
|
94
|
-
const microtaskIdx = doHydrateBlock.indexOf('queueMicrotask')
|
|
95
|
-
const deleteIdx = doHydrateBlock.indexOf('delete (globalThis).__CER_DATA__')
|
|
96
|
-
expect(deleteIdx).toBeGreaterThan(microtaskIdx)
|
|
103
|
+
expect(doHydrateBlock).not.toContain('queueMicrotask(() => { delete (globalThis).__CER_DATA__ })')
|
|
97
104
|
})
|
|
98
105
|
})
|
|
99
106
|
|
|
@@ -108,6 +115,11 @@ describe('APP_ENTRY_TEMPLATE — loader sequence', () => {
|
|
|
108
115
|
expect(APP_ENTRY_TEMPLATE).toContain('(globalThis).__CER_DATA__ = data')
|
|
109
116
|
})
|
|
110
117
|
|
|
118
|
+
it('_loadPageForPath derives primitive attrs from reused loader data too', () => {
|
|
119
|
+
expect(APP_ENTRY_TEMPLATE).toContain('function _toLoaderAttrs(data)')
|
|
120
|
+
expect(APP_ENTRY_TEMPLATE).toContain('loaderAttrs = { ...loaderAttrs, ..._toLoaderAttrs(loaderData) }')
|
|
121
|
+
})
|
|
122
|
+
|
|
111
123
|
it('_loadPageForPath merges loader primitive values into _currentPageAttrs', () => {
|
|
112
124
|
expect(APP_ENTRY_TEMPLATE).toContain('_currentPageAttrs = loaderAttrs')
|
|
113
125
|
})
|
|
@@ -7,6 +7,12 @@ describe('ENTRY_CLIENT_TEMPLATE', () => {
|
|
|
7
7
|
expect(ENTRY_CLIENT_TEMPLATE.length).toBeGreaterThan(0)
|
|
8
8
|
})
|
|
9
9
|
|
|
10
|
+
it('marks the SSR entry URL before app boots so generated middleware can skip hydration replay once', () => {
|
|
11
|
+
expect(ENTRY_CLIENT_TEMPLATE).toContain('(globalThis).__CER_HYDRATION_ENTRY__')
|
|
12
|
+
expect(ENTRY_CLIENT_TEMPLATE).toContain('path: window.location.pathname')
|
|
13
|
+
expect(ENTRY_CLIENT_TEMPLATE).toContain('new URLSearchParams(window.location.search)')
|
|
14
|
+
})
|
|
15
|
+
|
|
10
16
|
it('captures __CER_DATA__ from window to globalThis before app boots', () => {
|
|
11
17
|
// SSR loader data must be captured before any module clears window.__CER_DATA__
|
|
12
18
|
expect(ENTRY_CLIENT_TEMPLATE).toContain('window.__CER_DATA__')
|
|
@@ -33,7 +39,7 @@ describe('ENTRY_CLIENT_TEMPLATE', () => {
|
|
|
33
39
|
// Guards are required so the template works in SSR environments where
|
|
34
40
|
// window is not defined.
|
|
35
41
|
const windowChecks = (ENTRY_CLIENT_TEMPLATE.match(/typeof window !== 'undefined'/g) ?? []).length
|
|
36
|
-
expect(windowChecks).toBeGreaterThanOrEqual(
|
|
42
|
+
expect(windowChecks).toBeGreaterThanOrEqual(4)
|
|
37
43
|
})
|
|
38
44
|
|
|
39
45
|
it('captures globals before importing app.js', () => {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
|
|
4
|
+
// --- Module-level mock for @jasonshimmy/custom-elements-runtime ---
|
|
5
|
+
// usePageData imports getCurrentComponentContext from the runtime. We mock the
|
|
6
|
+
// module so unit tests can control which context (if any) is "active".
|
|
7
|
+
let _mockContext: Record<string, unknown> | null = null
|
|
8
|
+
|
|
9
|
+
vi.mock('@jasonshimmy/custom-elements-runtime', () => ({
|
|
10
|
+
getCurrentComponentContext: () => _mockContext,
|
|
11
|
+
}))
|
|
12
|
+
|
|
3
13
|
import { usePageData } from '../../runtime/composables/use-page-data.js'
|
|
4
14
|
|
|
5
15
|
describe('usePageData', () => {
|
|
@@ -119,3 +129,170 @@ describe('usePageData — AsyncLocalStorage (server-side)', () => {
|
|
|
119
129
|
})
|
|
120
130
|
})
|
|
121
131
|
})
|
|
132
|
+
|
|
133
|
+
// ─── Component context caching ─────────────────────────────────────────────
|
|
134
|
+
//
|
|
135
|
+
// usePageData() caches the result on the component context (via Object.defineProperty)
|
|
136
|
+
// so that re-renders of the same element instance return the same value even after
|
|
137
|
+
// __CER_DATA__ is deleted by the post-hydration queueMicrotask cleanup.
|
|
138
|
+
//
|
|
139
|
+
// Background: renderFn passed to component() IS the render function — it runs on
|
|
140
|
+
// every re-render, not just once as a setup phase. Without caching, calling
|
|
141
|
+
// usePageData() on the second render after __CER_DATA__ is deleted returns null,
|
|
142
|
+
// which flips `ssrData ? 'ssr' : 'client'` guards and re-triggers client fetches.
|
|
143
|
+
|
|
144
|
+
describe('usePageData — component context caching', () => {
|
|
145
|
+
const _PAGE_DATA_KEY = '_cerPageData'
|
|
146
|
+
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
149
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA_STORE__']
|
|
150
|
+
_mockContext = null
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
155
|
+
_mockContext = null
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns null and does not cache when context is null (no render in progress)', () => {
|
|
159
|
+
_mockContext = null
|
|
160
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
|
|
161
|
+
const result = usePageData()
|
|
162
|
+
expect(result).toEqual({ value: 42 })
|
|
163
|
+
// No context to cache on — nothing to assert, just no crash
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('caches the result on the component context using Object.defineProperty', () => {
|
|
167
|
+
const ctx: Record<string, unknown> = {}
|
|
168
|
+
_mockContext = ctx
|
|
169
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
|
|
170
|
+
|
|
171
|
+
usePageData()
|
|
172
|
+
|
|
173
|
+
// Should be defined on ctx via Object.defineProperty (non-enumerable)
|
|
174
|
+
expect(Object.prototype.hasOwnProperty.call(ctx, _PAGE_DATA_KEY)).toBe(true)
|
|
175
|
+
expect(ctx[_PAGE_DATA_KEY]).toEqual({ value: 42 })
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('cached property is non-enumerable (does not appear in Object.keys)', () => {
|
|
179
|
+
const ctx: Record<string, unknown> = {}
|
|
180
|
+
_mockContext = ctx
|
|
181
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
|
|
182
|
+
|
|
183
|
+
usePageData()
|
|
184
|
+
|
|
185
|
+
expect(Object.keys(ctx)).not.toContain(_PAGE_DATA_KEY)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('cached property is non-writable (reactive proxy set-trap cannot overwrite it)', () => {
|
|
189
|
+
const ctx: Record<string, unknown> = {}
|
|
190
|
+
_mockContext = ctx
|
|
191
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
|
|
192
|
+
|
|
193
|
+
usePageData()
|
|
194
|
+
|
|
195
|
+
// Attempting to overwrite a writable:false property silently fails in non-strict mode
|
|
196
|
+
// and throws in strict mode. Verify the value doesn't change.
|
|
197
|
+
try { ctx[_PAGE_DATA_KEY] = { value: 999 } } catch { /* strict mode */ }
|
|
198
|
+
expect(ctx[_PAGE_DATA_KEY]).toEqual({ value: 42 })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('returns cached value on second call even after __CER_DATA__ is deleted', () => {
|
|
202
|
+
const ctx: Record<string, unknown> = {}
|
|
203
|
+
_mockContext = ctx
|
|
204
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
|
|
205
|
+
|
|
206
|
+
// First call (simulates initial render while __CER_DATA__ is present)
|
|
207
|
+
const first = usePageData()
|
|
208
|
+
expect(first).toEqual({ value: 42 })
|
|
209
|
+
|
|
210
|
+
// Simulate post-hydration cleanup: queueMicrotask(() => delete __CER_DATA__)
|
|
211
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
212
|
+
|
|
213
|
+
// Second call (simulates re-render after cleanup) — must return cached value
|
|
214
|
+
const second = usePageData()
|
|
215
|
+
expect(second).toEqual({ value: 42 })
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('caches null result so repeated calls with no data do not re-read the deleted global', () => {
|
|
219
|
+
const ctx: Record<string, unknown> = {}
|
|
220
|
+
_mockContext = ctx
|
|
221
|
+
// No __CER_DATA__ — returns null and caches null
|
|
222
|
+
|
|
223
|
+
const first = usePageData()
|
|
224
|
+
expect(first).toBeNull()
|
|
225
|
+
|
|
226
|
+
// Set __CER_DATA__ AFTER first call — second call should return cached null
|
|
227
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 99 }
|
|
228
|
+
const second = usePageData()
|
|
229
|
+
expect(second).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('each component element instance has its own independent cache', () => {
|
|
233
|
+
const ctxA: Record<string, unknown> = {}
|
|
234
|
+
const ctxB: Record<string, unknown> = {}
|
|
235
|
+
|
|
236
|
+
// First component: has SSR data
|
|
237
|
+
_mockContext = ctxA
|
|
238
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { page: 'home' }
|
|
239
|
+
const resultA = usePageData()
|
|
240
|
+
expect(resultA).toEqual({ page: 'home' })
|
|
241
|
+
|
|
242
|
+
// Simulate navigation: delete home data, set blog data
|
|
243
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
244
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { page: 'blog' }
|
|
245
|
+
|
|
246
|
+
// Second component (different element, different context)
|
|
247
|
+
_mockContext = ctxB
|
|
248
|
+
const resultB = usePageData()
|
|
249
|
+
expect(resultB).toEqual({ page: 'blog' })
|
|
250
|
+
|
|
251
|
+
// First component re-rendered — still returns its cached value
|
|
252
|
+
_mockContext = ctxA
|
|
253
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
254
|
+
const resultA2 = usePageData()
|
|
255
|
+
expect(resultA2).toEqual({ page: 'home' }) // cached, not stale blog data
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('ALS path takes precedence over context cache (server-side always uses ALS)', () => {
|
|
259
|
+
const store = new AsyncLocalStorage<unknown>()
|
|
260
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA_STORE__'] = store
|
|
261
|
+
|
|
262
|
+
const ctx: Record<string, unknown> = {}
|
|
263
|
+
// Pre-seed a stale client cache on the context
|
|
264
|
+
Object.defineProperty(ctx, _PAGE_DATA_KEY, { value: { stale: true }, writable: false, configurable: true, enumerable: false })
|
|
265
|
+
_mockContext = ctx
|
|
266
|
+
|
|
267
|
+
const alsData = { fresh: true }
|
|
268
|
+
store.run(alsData, () => {
|
|
269
|
+
const result = usePageData()
|
|
270
|
+
// ALS wins — context cache is not consulted for server-side renders
|
|
271
|
+
expect(result).toEqual(alsData)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA_STORE__']
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('context cache survives across multiple renderFn() invocations (re-render stability)', () => {
|
|
278
|
+
const ctx: Record<string, unknown> = {}
|
|
279
|
+
_mockContext = ctx
|
|
280
|
+
;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { title: 'My Page' }
|
|
281
|
+
|
|
282
|
+
// Simulate 5 re-renders (e.g. reactive state updates)
|
|
283
|
+
for (let i = 0; i < 5; i++) {
|
|
284
|
+
const result = usePageData<{ title: string }>()
|
|
285
|
+
expect(result?.title).toBe('My Page')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Delete __CER_DATA__ (post-hydration cleanup)
|
|
289
|
+
delete (globalThis as Record<string, unknown>)['__CER_DATA__']
|
|
290
|
+
|
|
291
|
+
// 5 more re-renders post-cleanup
|
|
292
|
+
for (let i = 0; i < 5; i++) {
|
|
293
|
+
const result = usePageData<{ title: string }>()
|
|
294
|
+
expect(result?.title).toBe('My Page')
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
@@ -449,6 +449,17 @@ export async function generateRoutesCode(pagesDir: string, i18n?: I18nRouteConfi
|
|
|
449
449
|
const mwLiteral = JSON.stringify(mw)
|
|
450
450
|
mwChainBody = (
|
|
451
451
|
` beforeEnter: async (to, from) => {\n` +
|
|
452
|
+
` const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__\n` +
|
|
453
|
+
` if (\n` +
|
|
454
|
+
` _hydrationEntry &&\n` +
|
|
455
|
+
` from.path === to.path &&\n` +
|
|
456
|
+
` JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {}) &&\n` +
|
|
457
|
+
` _hydrationEntry.path === to.path &&\n` +
|
|
458
|
+
` JSON.stringify(_hydrationEntry.query ?? {}) === JSON.stringify(to.query ?? {})\n` +
|
|
459
|
+
` ) {\n` +
|
|
460
|
+
` delete (globalThis).__CER_HYDRATION_ENTRY__\n` +
|
|
461
|
+
` return true\n` +
|
|
462
|
+
` }\n` +
|
|
452
463
|
` const { middleware } = await import('virtual:cer-middleware')\n` +
|
|
453
464
|
` const _names = ${mwLiteral}\n` +
|
|
454
465
|
` let _idx = 0\n` +
|