@pylonsync/functions 0.22.60 → 0.22.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/offline-sw.d.ts +2 -0
- package/dist/ssr-client-bundler.d.ts +1 -1
- package/dist/ssr-page-tree.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +29 -9
- package/package.json +1 -1
- package/src/offline-sw.test.ts +36 -1
- package/src/offline-sw.ts +26 -1
- package/src/ssr-client-bundler.ts +90 -10
- package/src/ssr-client-nav-runtime.fixture.ts +168 -8
- package/src/ssr-client-nav-runtime.test.ts +68 -4
- package/src/ssr-metadata.test.ts +251 -0
- package/src/ssr-page-tree.ts +5 -8
- package/src/ssr-runtime.ts +215 -41
package/dist/offline-sw.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export type RequestKind =
|
|
|
24
24
|
"navigate"
|
|
25
25
|
/** A file of the client build: cache first. */
|
|
26
26
|
| "build"
|
|
27
|
+
/** The build manifest: network first, the cached copy when offline. */
|
|
28
|
+
| "build-manifest"
|
|
27
29
|
/** The web app manifest or an icon: cache first. */
|
|
28
30
|
| "precached"
|
|
29
31
|
/** Not intercepted, never cached. */
|
|
@@ -82,7 +82,7 @@ export declare function generateLoadingRegistry(components: string[]): string;
|
|
|
82
82
|
* exposes `window.__pylon` for the `<Link>` component to call
|
|
83
83
|
* directly (prefetch, navigate).
|
|
84
84
|
*/
|
|
85
|
-
export declare const CLIENT_RUNTIME_SOURCE = "// Generated by Pylon SSR (Phase 2 client runtime).\n// DO NOT EDIT \u2014 overwritten on every pylon dev / build.\n\nimport { createElement, Fragment, Suspense } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { configureClient, registerOfflineWorker } from \"@pylonsync/react\";\nimport { createPylonBoundary, nearestBoundaryComponent } from \"./client-boundary\";\nimport { LOADING_MODULES } from \"./loading-registry\";\nimport { createNavPayloadCache } from \"./nav-cache\";\nimport { matchRoute, prefetchTargets } from \"./route-match\";\nimport { clientRouteFor, isOfflinePath } from \"./client-nav\";\nimport { withMetaSlot } from \"./page-tree\";\n\nconst routeCache = Object.create(null);\nlet activeRoot = null;\n// Component path of the route currently mounted (the manifest key). Its cached\n// layout chain is what a pending-navigation skeleton renders inside.\nlet currentComponent = null;\n// Destination of an in-flight client navigation. Read by hydrateRoot's\n// onUncaughtError so a re-render that throws mid-nav degrades to a full page\n// load instead of a white screen. Null when no nav is in flight.\nlet pendingNav = null;\nlet manifestPromise = null;\nconst prefetchedChunks = new Set();\n\nfunction buildTree(Page, Layouts, props, loadingFor) {\n let tree = createElement(Page, props);\n // The server wraps the page of a route with a loading.tsx in ONE Suspense\n // boundary, fallback = that loading module (ssr-runtime.ts, #278). The\n // tree that hydrates the server HTML must have the same boundary, or React\n // finds <Suspense> where it expects the page's first element, throws a\n // hydration mismatch (#418) and re-renders the whole document; clicks in\n // that window are lost. `loadingFor` is the page's component path.\n const Loading = loadingFor ? nearestLoading(loadingFor) : null;\n if (Loading) {\n tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);\n }\n // The server renders the page's metadata in this slot; the browser leaves\n // it empty. Same shape on both sides, so useId values match (page-tree.ts).\n tree = withMetaSlot(createElement, Fragment, null, tree);\n for (let i = Layouts.length - 1; i >= 0; i--) {\n const Layout = Layouts[i];\n if (!Layout) continue;\n tree = createElement(Layout, props, tree);\n }\n return tree;\n}\n\n// The loading module the server wraps `component` in: the nearest\n// loading.tsx walking up from the page's directory.\nfunction nearestLoading(component) {\n const key = nearestBoundaryComponent(component, \"loading\", Object.keys(LOADING_MODULES));\n return key ? LOADING_MODULES[key] : null;\n}\n\n// ---------------------------------------------------------------------------\n// Client error / not-found boundary. notFound() (from @pylonsync/react) and\n// any other error thrown DURING a client render \u2014 the normal case when an\n// async lookup 404s after hydration \u2014 would otherwise propagate uncaught and\n// blank the whole document. React error boundaries must be class components;\n// this one catches the throw, resolves the nearest not-found.tsx / error.tsx\n// for the active route from the build manifest (the same nearest-ancestor\n// model the server's findBoundary uses), and renders it in its own layout\n// chain. It sits at the root and is transparent until something throws, so\n// hydration still matches the server HTML exactly. It resets on navigation\n// via a changing navEpoch prop (NOT a key \u2014 a key change would remount every\n// layout on each nav and lose their state).\n// ---------------------------------------------------------------------------\n\nlet navEpoch = 0;\n// The props the active page was rendered with (auth, serverData, params, \u2026).\n// The boundary reuses them so a boundary module AND its layout chain \u2014 e.g. an\n// auth-guarding dashboard layout that reads props.auth \u2014 render with the SAME\n// context the page had. The server renders boundaries with the page props too;\n// a minimal hand-built props object would make such a layout see no auth and\n// (for instance) redirect to /login instead of showing the not-found page.\nlet currentPageProps = {};\n\n// Seed data for the in-flight navigation \u2014 the object handed to <Link seed>,\n// exposed to the destination page via useRouteSeed() so it can paint its\n// Suspense fallback with real content before the SSR fetch lands. Set at the\n// start of every navigation (to the seed, or null), so useRouteSeed() reads a\n// value that's consistent for the whole nav. Null on hard load / seedless nav.\nlet currentSeed = null;\n\n// The not-found / error boundary lives in a real module (./client-boundary,\n// emitted next to this file by the bundler) so its render behavior is\n// unit-testable instead of buried in this string. Wire the runtime's\n// internals in as deps; the returned withBoundary(tree, component, navEpoch)\n// wraps every page tree as the transparent root error boundary.\nconst { withBoundary } = createPylonBoundary({\n loadManifest,\n loadRouteEntry,\n navigate,\n buildTree,\n getPageProps: () => currentPageProps,\n getResetHref: () => location.pathname + location.search,\n});\n\n// Deterministic stringify \u2014 MUST match stableStringify in ssr-runtime.ts so\n// a serverData call's cache key is identical on server and client.\nfunction stableStringify(v) {\n if (v === null || v === undefined || typeof v !== \"object\") {\n return JSON.stringify(v === undefined ? null : v);\n }\n if (Array.isArray(v)) return \"[\" + v.map(stableStringify).join(\",\") + \"]\";\n const keys = Object.keys(v).sort();\n return (\n \"{\" +\n keys.map((k) => JSON.stringify(k) + \":\" + stableStringify(v[k])).join(\",\") +\n \"}\"\n );\n}\n\nconst SERVER_DATA_METHODS = [\n \"get\",\n \"list\",\n \"lookup\",\n \"query\",\n \"queryGraph\",\n \"paginate\",\n \"search\",\n];\n\n// A React-recognized fulfilled thenable: use() reads .value synchronously\n// (status === \"fulfilled\") instead of suspending. Critical for hydration \u2014\n// the server streamed the post-Suspense content, so the client must render\n// that content on the FIRST pass without re-suspending (no fallback flash,\n// no mismatch).\nfunction fulfilledThenable(value) {\n return {\n status: \"fulfilled\",\n value,\n then(onFulfilled) {\n return onFulfilled ? onFulfilled(value) : value;\n },\n };\n}\n\n// Client stand-in for the server's serverData handle. Each method returns a\n// pre-fulfilled thenable (cached per key) sourced from the SSR'd ssrData map,\n// keyed identically to the server. Misses yield undefined \u2014 the page\n// rendered with whatever the server fetched, so a hit is expected.\nfunction makeClientServerData(ssrData) {\n const cache = ssrData || {};\n const pc = new Map();\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n // serverData.fn(name, args) \u2014 key MUST match the server's makeServerData\n // (\"fn:\" + name + \":\" + stableStringify(args ?? {})) so hydration replays\n // the query function's SSR'd result without a mismatch.\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n return sd;\n}\n\n// serverData stand-in used during the OPTIMISTIC first paint of a navigation:\n// every method returns a never-resolving thenable, so a page that use()s it\n// suspends and shows its Suspense fallback (which useRouteSeed() renders from\n// the clicked-link seed). The thenable is cached per key so React never sees an\n// \"uncached promise\" if it re-renders the optimistic tree. It never resolves on\n// its own \u2014 navigate() replaces the whole tree with real, fulfilled serverData\n// the moment the SSR fetch lands, which is what actually swaps fallback\u2192content.\nfunction makePendingServerData() {\n const pc = new Map();\n const pending = () => ({ then() {} });\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n return sd;\n}\n\n// Server-only response controller has no meaning on the client (the status/\n// redirect/cookies already shipped). Give pages a no-op so a body that\n// touches props.response during hydration doesn't crash.\nfunction makeNoopResponse() {\n const noop = () => {};\n return {\n setStatus: noop,\n setHeader: noop,\n setCookie: noop,\n redirect: noop,\n notFound: noop,\n };\n}\n\n// Rehydrate the live, server-only props (serverData + response) that were\n// stripped before serialization, so the client tree matches the server's.\n// For a hydrated error boundary (#279), synthesize the reset() the server\n// rendered as a no-op: re-fetch + re-render the current URL (a transient\n// error clears to the page; a deterministic one re-shows the boundary).\n// The current route's dynamic params (e.g. { projectId: \"p_1\" }). Lives here,\n// not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()\n// in a deep client child has a reactive source. A fresh object per nav \u2192 stable\n// reference between navs (useSyncExternalStore needs that).\nlet currentParams = {};\nfunction setNavParamsRaw(params) {\n currentParams = params || {};\n}\nfunction setNavParams(data) {\n setNavParamsRaw(data && data.props && data.props.params);\n}\n\nfunction withClientProps(data) {\n const props = { ...(data.props || {}) };\n props.serverData = makeClientServerData(data.ssrData);\n props.response = makeNoopResponse();\n if (data.kind === \"error\") {\n props.reset = function () {\n navigate(location.pathname + location.search, { replace: true });\n };\n }\n return props;\n}\n\nfunction readPylonData() {\n const dataEl = document.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) return null;\n try {\n return JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n return null;\n }\n}\n\nasync function loadManifest() {\n if (!manifestPromise) {\n manifestPromise = fetch(\"/_pylon/build/manifest.json\", {\n credentials: \"same-origin\",\n })\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null);\n }\n return manifestPromise;\n}\n\nfunction preloadChunks(routeInfo) {\n if (!routeInfo) return;\n const prefix = routeInfo.public_prefix || \"/_pylon/build/\";\n const all = [routeInfo.file, ...(routeInfo.imports || [])];\n for (const c of all) {\n // prefetch() passes file: \"\" (HTML-only prefetch has no route entry);\n // an empty name would modulepreload the bare prefix (\"/_pylon/build/\")\n // and the host answers that with a 400 on every page load.\n if (!c) continue;\n if (prefetchedChunks.has(c)) continue;\n prefetchedChunks.add(c);\n const link = document.createElement(\"link\");\n link.rel = \"modulepreload\";\n link.href = prefix + c;\n document.head.appendChild(link);\n }\n}\n\n// Run fn once the page itself has finished loading. modulepreload fetches at\n// high priority, and a sidebar of links warms on first paint (Link's\n// IntersectionObserver), so warming a dozen route chunks inline would compete\n// with the current page's own scripts and data \u2014 buying a faster second\n// navigation with a slower first render.\nfunction whenLoaded(fn) {\n if (document.readyState === \"complete\") {\n fn();\n return;\n }\n window.addEventListener(\"load\", fn, { once: true });\n}\n\n// Run fn when the browser is idle after the page loaded, so warming the next\n// route never competes with this one.\nfunction whenIdle(fn) {\n whenLoaded(() => {\n if (typeof window.requestIdleCallback === \"function\") {\n window.requestIdleCallback(fn, { timeout: 2000 });\n } else {\n setTimeout(fn, 200);\n }\n });\n}\n\n// Import a client-navigable route's entry ahead of the click, so navigate()\n// finds it in routeCache and renders in the same frame. Only after the first\n// hydration: an entry that loads before it would be taken for the page.\nfunction warmRouteEntry(component) {\n if (!activeRoot || routeCache[component]) return;\n loadRouteEntry(component).catch(() => {});\n}\n\n// Marks a request as a client-side NAVIGATION rather than a document load.\n// The server answers with the head metadata + __PYLON_DATA__ and skips the\n// page markup, which this runtime re-renders from that data anyway. The host\n// keys its render cache on the same header, so the two shapes never share an\n// entry. Prefetch and navigate MUST send identical headers, or the prefetched\n// response is the wrong shape for the click that consumes it.\nconst NAV_REQUEST_HEADERS = { Accept: \"text/html\", \"X-Pylon-Nav\": \"1\" };\n\n// Payloads warmed by a hover, consumed by the click that follows. See\n// ./nav-cache for why this is in memory rather than an HTTP-level prefetch.\nconst navPayloads = createNavPayloadCache({\n now: () => Date.now(),\n fetchPage: (target) =>\n fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n }).then((res) => {\n // A redirect means the URL navigate() would commit isn't the one that\n // answered; let the real navigation resolve that itself.\n if (!res.ok || res.redirected) return null;\n return res.text();\n }),\n});\n\nasync function prefetch(href, opts) {\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) return;\n const manifest = await loadManifest();\n // A route that renders from the client never fetches its page, so there is\n // no payload to warm. Its entry is warmed instead, below.\n const clientRoute = clientRouteFor(manifest, url.pathname, matchRoute);\n // The page payload, but ONLY on a real intent signal (hover / touch). It\n // costs a full SSR render, so warming a screenful of links on sight would\n // spend a dozen renders per page load to save one.\n if (opts && opts.document && !clientRoute) {\n navPayloads.prefetch(url.pathname + url.search);\n }\n if (!manifest) return;\n // Chunk prefetch. The page payload above is the half that was already\n // cheap: nothing can render until the destination's own entry chunk lands,\n // so warming only the HTML leaves a full round-trip on the click.\n const targets = prefetchTargets(manifest, url.pathname);\n const warm = () => {\n preloadChunks({\n public_prefix: manifest.public_prefix,\n file: targets.file,\n imports: targets.imports,\n });\n if (clientRoute) warmRouteEntry(clientRoute.component);\n };\n // Intent (hover, touch, pointerdown, focus): the tap is coming, fetch now.\n // Otherwise (a link on screen) wait until the page has painted and the\n // browser is idle, so warming routes never counts against this page's\n // first paint, and skip it on a connection the user asked to save.\n if (opts && opts.document) warm();\n else if (!saveData()) whenIdle(warm);\n}\n\n// The user asked for less data (Save-Data), or the connection is 2G.\n// Prefetch on sight costs bytes the user may never use.\nfunction saveData() {\n const c = typeof navigator !== \"undefined\" ? navigator.connection : undefined;\n if (!c) return false;\n return c.saveData === true || /(^|-)2g$/.test(c.effectiveType || \"\");\n}\n\nasync function loadRouteEntry(component) {\n if (routeCache[component]) return routeCache[component];\n const manifest = await loadManifest();\n if (!manifest) throw new Error(\"manifest unavailable\");\n const routeInfo = manifest.routes[component];\n if (!routeInfo) throw new Error(\"unknown component \" + component);\n const prefix = manifest.public_prefix || \"/_pylon/build/\";\n preloadChunks({ ...routeInfo, public_prefix: prefix });\n // Dynamic import the entry. It will call hydrate(...) which\n // populates routeCache[component], then this returns.\n await import(/* @vite-ignore */ prefix + routeInfo.file);\n if (!routeCache[component]) {\n throw new Error(\"route entry did not register: \" + component);\n }\n return routeCache[component];\n}\n\nexport function hydrate(component, Page, Layouts, metadata) {\n // Always cache the route for nav. `metadata` is the page's static\n // `metadata` export: a navigation with no page fetch takes its title.\n routeCache[component] = { Page, Layouts, metadata };\n // Start the manifest fetch NOW rather than on first use. Nothing can resolve\n // a route without it \u2014 the first <Link> reaching the viewport warms its\n // chunks only after it lands \u2014 so leaving it lazy put a round trip in front\n // of all prefetching. In flight from here, it resolves during hydration.\n void loadManifest();\n const data = readPylonData();\n // Namespace client storage by the app's manifest name (injected into\n // __PYLON_DATA__ as `app`) BEFORE any db hook / auth helper reads a token.\n // Without this every app on a shared origin (all localhost:4321 in dev)\n // shares the default `pylon_token` and clobbers each other's sessions.\n // configureClient runs once here; it also migrates a legacy default-keyspace\n // token so an existing session survives the switch.\n if (data && data.app) configureClient({ appName: data.app });\n // First hydrate: the entry's component MATCHES the SSR'd page.\n // Establish the root + install the click + popstate handlers\n // exactly once.\n if (!activeRoot) {\n if (!data || data.component !== component) {\n console.warn(\n \"[pylon ssr] entry/__PYLON_DATA__ mismatch \u2014 initial hydration skipped\",\n );\n return;\n }\n setNavParams(data);\n currentPageProps = withClientProps(data);\n currentComponent = data.component;\n const tree = withBoundary(\n buildTree(Page, Layouts, currentPageProps, data.component),\n data.component,\n navEpoch,\n );\n activeRoot = hydrateRoot(document, tree, {\n // Safety net for client navigation. If a nav re-render throws an uncaught\n // error in React's commit phase, the URL has already changed but the page\n // can't swap \u2014 a white/stale screen. The classic trigger is a page that\n // renders hoisted <title>/<meta>/<link> in its own tree (use the\n // `metadata` export instead); React 19 owns those head nodes on the client\n // and reconciling them across routes can throw. Rather than strand the\n // user, fall back to a full page load of the pending destination, which\n // re-renders it cleanly from SSR. Non-navigation errors keep React's\n // default reporting.\n onUncaughtError(error) {\n if (pendingNav) {\n const dest = pendingNav;\n pendingNav = null;\n console.error(\n \"[pylon ssr] client navigation failed to render; falling back to a full page load:\",\n error,\n );\n window.location.href = dest;\n return;\n }\n console.error(error);\n },\n });\n installNavHandlers();\n // Registers the offline service worker for an app that opted in, and\n // removes one left on this origin by an earlier build otherwise.\n // `build` is the build this page runs, for useAppUpdate. An option\n // rather than its own import, so an app on an older @pylonsync/react\n // still builds.\n registerOfflineWorker({\n optedIn: !!data.offline,\n production: !(data.offline && data.offline.dev),\n signedIn: !!(data.props && data.props.session && data.props.session.exists),\n build:\n data.offline && data.offline.version\n ? { version: data.offline.version, builtAt: data.offline.builtAt || null }\n : undefined,\n });\n if (data.offline) {\n // The service worker answered this load with the page it kept for\n // another URL: a dynamic offline route, or no network. The page on\n // screen is that URL's; render the address bar's URL from the replica.\n const shellPath = data.props && data.props.pathname;\n if (typeof shellPath === \"string\" && shellPath !== location.pathname) {\n navigate(location.pathname + location.search + location.hash, {\n replace: true,\n shellPath,\n });\n }\n }\n return;\n }\n // Subsequent hydrate calls fire from dynamic-loaded entries\n // during navigation. The render is driven by navigate(), so we\n // only need to populate the cache (already done above).\n}\n\n// Swap the page's SEO/social <head> tags on a client-side navigation.\n// The SSR runtime marks every page-metadata <meta>/<link> with\n// data-pylon-meta; we drop the current set and import the incoming page's\n// set, so description / canonical / og:* / twitter:* / icons track the new\n// route. The layout's charset/viewport and Pylon's injected stylesheet\n// links carry no marker, so they survive untouched (no FOUC). The page\n// component never renders these tags on the client, so React doesn't own\n// them \u2014 this manual swap can't fight hydration.\nfunction syncHeadMeta(doc) {\n const head = document.head;\n if (!head) return;\n const current = head.querySelectorAll(\"[data-pylon-meta]\");\n for (let i = 0; i < current.length; i++) current[i].remove();\n const incoming = doc.head\n ? doc.head.querySelectorAll(\"[data-pylon-meta]\")\n : [];\n for (let i = 0; i < incoming.length; i++) {\n head.appendChild(document.importNode(incoming[i], true));\n }\n}\n\n// How long a navigation may run before the destination's loading.tsx takes\n// over the page area. Below this, the transition finishes on its own and\n// swapping to a skeleton would read as a flicker rather than as feedback.\nconst PENDING_PLACEHOLDER_MS = 100;\n\n// Paint the destination's nearest loading.tsx in place of the current page,\n// so a click that can't complete immediately is still acknowledged. Returns\n// whether anything was rendered \u2014 apps without a loading.tsx keep the old\n// behavior (previous page stays put until the new one is ready).\n//\n// Rendered inside the CURRENTLY MOUNTED layout chain, not the destination's:\n// the destination's layouts live in the chunk this skeleton exists to cover,\n// so waiting for them would defeat the point. Sibling routes (the common case\n// for a nav that's slow enough to reach here) share that chain anyway, and\n// React keeps those layouts mounted across the swap \u2014 only the page area\n// changes.\n//\n// Props are the current page's, verbatim. Layouts re-render with the data they\n// already had, so one that read serverData or params can't suspend on a\n// never-resolving handle or crash on a cache miss halfway through a\n// transition. A skeleton has no data of its own to show.\nfunction renderPendingPlaceholder(pathname, manifest, myEpoch) {\n if (!activeRoot || !currentComponent) return false;\n const matched = matchRoute(manifest, pathname);\n if (!matched) return false;\n const key = nearestBoundaryComponent(\n matched.component,\n \"loading\",\n Object.keys(LOADING_MODULES),\n );\n const Loading = key ? LOADING_MODULES[key] : null;\n if (!Loading) return false;\n const current = routeCache[currentComponent];\n const tree = withBoundary(\n buildTree(Loading, (current && current.Layouts) || [], currentPageProps),\n currentComponent,\n myEpoch,\n );\n activeRoot.render(tree);\n return true;\n}\n\n// The path and query the page last rendered. A popstate that keeps them and\n// only changes the hash is a same-page jump (an anchor link, a back press\n// between anchors), not a navigation: re-rendering for it would reset the\n// scroll to the top.\nlet renderedPath = \"\";\n\nasync function navigate(href, opts) {\n const push = !opts || opts.push !== false;\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) {\n window.location.href = href;\n return;\n }\n const target = url.pathname + url.search;\n\n // Claim an epoch for this navigation. A later navigate() bumps navEpoch, so\n // every await below can bail once it's been superseded by a newer nav \u2014\n // otherwise a slow fetch could render a stale destination over a newer one.\n const myEpoch = ++navEpoch;\n currentSeed = opts && opts.seed != null ? opts.seed : null;\n\n // Flipped the moment this nav commits or gives up, so a pending-placeholder\n // timer that fires late can never paint a skeleton over real content.\n const navState = { settled: false };\n\n // ---- Instant navigation --------------------------------------------------\n // A route whose page exports `navigation = \"client\"`, or whose URL is an\n // offline route, renders from the client with no page fetch: its data comes\n // from the local replica. The manifest is in hand after hydration, and a\n // Link warms the entry ahead of the click, so this paints in the same frame.\n if (activeRoot) {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = clientRouteFor(manifest, url.pathname, matchRoute);\n if (matched) {\n let route = null;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n // Fall through to the fetch below, which reloads the page when the\n // entry cannot load.\n console.warn(\"[pylon ssr] client navigation fell back to a page fetch:\", e);\n }\n if (myEpoch !== navEpoch) return;\n if (route) {\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return;\n }\n }\n }\n const fullLoad = () => {\n navState.settled = true;\n window.location.href = href;\n };\n\n // ---- Optimistic first paint --------------------------------------------\n // With a seed AND a client-resolvable route, render the destination NOW \u2014\n // before the SSR fetch \u2014 with a pending serverData, so the page shows its\n // Suspense fallback painted from the seed (via useRouteSeed). The real render\n // below swaps in full data in place. Any failure (no route match, chunk load\n // error) falls through to the normal blocking fetch-then-render path, so a\n // seed can never break navigation. Requires the page to keep its serverData\n // reads inside a <Suspense> (the store detail page does) \u2014 a bare top-level\n // use() would suspend the whole page with no seeded fallback to show.\n let didOptimistic = false;\n if (currentSeed != null && activeRoot) {\n try {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = matchRoute(manifest, url.pathname);\n if (matched) {\n const route = await loadRouteEntry(matched.component);\n if (myEpoch !== navEpoch) return;\n setNavParamsRaw(matched.params);\n currentPageProps = {\n params: matched.params,\n serverData: makePendingServerData(),\n response: makeNoopResponse(),\n };\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n matched.component,\n myEpoch,\n );\n pendingNav = target;\n currentComponent = matched.component;\n activeRoot.render(tree);\n if (opts && opts.replace) {\n history.replaceState({ component: matched.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: matched.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n didOptimistic = true;\n }\n } catch (e) {\n // Fall through to the normal fetch-then-render path below.\n }\n }\n\n // ---- Pending-state placeholder -----------------------------------------\n // Nothing below paints until the SSR payload AND the destination's chunk are\n // both in hand, which on a cold route is long enough for the click to look\n // ignored. Hand the page area to the destination's loading.tsx once the nav\n // outlives the flicker threshold. Skipped when a seed already painted the\n // real destination \u2014 that's strictly better than a skeleton.\n let placeholderTimer = null;\n if (!didOptimistic && activeRoot) {\n placeholderTimer = setTimeout(() => {\n placeholderTimer = null;\n // loadManifest resolves instantly once warm (Link prefetch primes it on\n // first paint); the settled check covers the cold first navigation,\n // where the fetch can win the race with it.\n loadManifest().then((manifest) => {\n if (navState.settled || myEpoch !== navEpoch) return;\n if (renderPendingPlaceholder(url.pathname, manifest, myEpoch)) {\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n }\n });\n }, PENDING_PLACEHOLDER_MS);\n }\n\n // ---- Real fetch + render -----------------------------------------------\n let html;\n try {\n // A hover-prefetch usually has this in hand, or in flight \u2014 either way the\n // click joins it instead of opening a second request for the same page.\n const prefetched = navPayloads.take(target);\n html = prefetched ? await prefetched : null;\n if (html == null) {\n const res = await fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n });\n if (!res.ok) {\n fullLoad();\n return;\n }\n html = await res.text();\n }\n } catch {\n // No network. An offline route renders from the replica; anything else\n // needs the server.\n if (myEpoch !== navEpoch) return;\n if (await renderOffline(url, myEpoch, opts, push)) {\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n return;\n }\n if (opts && opts.shellPath) {\n // This load already came from a kept page and the URL cannot open\n // offline. Reloading would get the same kept page again; show its URL.\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n history.replaceState({ component: currentComponent }, \"\", opts.shellPath);\n renderedPath = location.pathname + location.search;\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n return;\n }\n fullLoad();\n return;\n }\n if (myEpoch !== navEpoch) return;\n const doc = new DOMParser().parseFromString(html, \"text/html\");\n const dataEl = doc.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) {\n fullLoad();\n return;\n }\n let data;\n try {\n data = JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n fullLoad();\n return;\n }\n let route;\n try {\n route = await loadRouteEntry(data.component);\n } catch (e) {\n console.warn(\"[pylon ssr] nav fallback (entry load failed):\", e);\n fullLoad();\n return;\n }\n if (myEpoch !== navEpoch) return;\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n document.title = doc.title || document.title;\n syncHeadMeta(doc);\n setNavParams(data);\n // Keep currentSeed set for the rest of this nav: useRouteData renders the seed\n // as CONTENT (not a Suspense fallback) and upgrades it to real data in place,\n // so the seed must stay readable across the optimistic\u2192real render. It's reset\n // at the START of the next navigation. (Clearing it here would degrade the\n // page to its skeleton for a frame during the swap \u2014 a visible flash.)\n currentPageProps = withClientProps(data);\n // Reuse myEpoch (do NOT bump) so the optimistic tree and the real tree share\n // a boundary identity: React resolves the Suspense fallback into the real\n // content in place instead of remounting the whole subtree (no flash).\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n // Track the in-flight destination so hydrateRoot's onUncaughtError can fall\n // back to a full page load if this re-render throws in React's commit phase\n // (instead of leaving the URL changed but the page unswapped). Cleared on the\n // next macrotask once the commit has settled with no error.\n pendingNav = target;\n currentComponent = data.component;\n // Drop every other prefetched payload once a navigation commits: they were\n // rendered against the previous page's state, and whatever the user does\n // here can invalidate them. Hovering on the new page re-warms in one request.\n navPayloads.clear();\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (didOptimistic) {\n // URL + scroll already handled during the optimistic paint; only keep the\n // history entry's component in sync with the authoritative SSR component.\n history.replaceState({ component: data.component }, \"\", target);\n } else {\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n // Notify the router hooks (useSearchParams / usePathname) so deep children\n // re-read location after a Link click or a router.push(). popstate already\n // covers back/forward, but pushState/replaceState fire no event.\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n // After a successful nav, scroll to top (Next.js default).\n window.scrollTo(0, 0);\n }\n}\n\n// Render an offline route with no server, after the page fetch failed: the\n// page reads its data from the local replica (db.useQuery), so it needs only\n// its code, its params and the identity-free props the last page had. Returns\n// false when the URL is not an offline route, or its code is not on the\n// device.\nasync function renderOffline(url, myEpoch, opts, push) {\n const manifest = await loadManifest();\n if (!manifest || !activeRoot) return false;\n if (!isOfflinePath(manifest, url.pathname, matchRoute)) return false;\n const matched = matchRoute(manifest, url.pathname);\n if (!matched) return false;\n let route;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n return false;\n }\n if (myEpoch !== navEpoch) return true;\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return true;\n}\n\n// Commit a navigation rendered from the client (instant navigation, or an\n// offline route with no network). History, the navigation event and the\n// scroll reset are the same as a fetched navigation's.\nfunction renderFromClient(url, matched, route, myEpoch, opts, push) {\n const prev = currentPageProps || {};\n const searchParams = {};\n url.searchParams.forEach((v, k) => {\n searchParams[k] = v;\n });\n const data = {\n component: matched.component,\n props: {\n pathname: url.pathname,\n url: url.pathname,\n host: prev.host || \"\",\n params: matched.params,\n searchParams,\n auth: prev.auth,\n session: prev.session,\n headers: {},\n cookies: {},\n },\n ssrData: {},\n };\n setNavParams(data);\n currentPageProps = withClientProps(data);\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n const target = url.pathname + url.search + url.hash;\n // See the fetched path: a commit that throws falls back to a page load.\n pendingNav = target;\n currentComponent = data.component;\n navPayloads.clear();\n const title = route.metadata && route.metadata.title;\n if (typeof title === \"string\" && title) document.title = title;\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n}\n\nfunction installNavHandlers() {\n document.addEventListener(\"click\", (e) => {\n if (e.defaultPrevented) return;\n if (e.button !== 0) return;\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const target = e.target;\n if (!target || !target.closest) return;\n const link = target.closest(\"a[data-pylon-link]\");\n if (!link) return;\n const href = link.getAttribute(\"href\");\n if (!href) return;\n if (link.target && link.target !== \"\" && link.target !== \"_self\") return;\n if (href.startsWith(\"http://\") || href.startsWith(\"https://\") || href.startsWith(\"//\")) {\n // External \u2014 let the browser handle.\n return;\n }\n e.preventDefault();\n // A <Link seed> registers its seed in window.__pylonLinkSeeds keyed by the\n // anchor element; pass it through so navigate() can paint optimistically.\n const seeds =\n typeof window !== \"undefined\" ? window.__pylonLinkSeeds : undefined;\n const seed = seeds ? seeds.get(link) : undefined;\n navigate(href, seed != null ? { seed } : undefined);\n });\n renderedPath = location.pathname + location.search;\n window.addEventListener(\"popstate\", () => {\n const next = location.pathname + location.search;\n if (next === renderedPath) return;\n navigate(next, { push: false });\n });\n // Progressive-enhancement form submit (#276): intercept <form data-pylon-form>\n // so the page doesn't full-reload. fetch the same route.ts endpoint, follow\n // the handler's redirect, and swap the destination in via navigate(). Without\n // JS this listener never runs and the browser submits natively (POST \u2192\n // handler \u2192 303 \u2192 GET) \u2014 identical result, just a full navigation.\n document.addEventListener(\"submit\", (e) => {\n if (e.defaultPrevented) return;\n const form = e.target;\n if (!form || form.tagName !== \"FORM\") return;\n if (!form.hasAttribute(\"data-pylon-form\")) return;\n const action = form.getAttribute(\"action\");\n if (!action) return;\n // Off-origin actions / new-tab targets \u2192 let the browser submit.\n if (action.startsWith(\"http://\") || action.startsWith(\"https://\") || action.startsWith(\"//\")) return;\n const tgt = form.getAttribute(\"target\");\n if (tgt && tgt !== \"\" && tgt !== \"_self\") return;\n const method = (form.getAttribute(\"method\") || \"post\").toUpperCase();\n e.preventDefault();\n // urlencoded body (matches the server's parser; files use the native path).\n const body = new URLSearchParams();\n const fd = new FormData(form);\n fd.forEach((v, k) => {\n if (typeof v === \"string\") body.append(k, v);\n });\n fetch(action, {\n method,\n body,\n credentials: \"same-origin\",\n headers: { Accept: \"text/html\" },\n })\n .then((res) => {\n // fetch followed the handler's 303 \u2192 res.url is the destination page.\n // Drive a client navigation to it (re-renders without a full reload).\n const dest = new URL(res.url || action, location.href);\n if (dest.origin === location.origin) {\n navigate(dest.pathname + dest.search);\n } else {\n window.location.href = dest.href;\n }\n })\n .catch(() => {\n // Network/abort \u2014 fall back to a native submit so the user isn't stuck.\n form.removeAttribute(\"data-pylon-form\");\n form.submit();\n });\n });\n}\n\n// Expose for <Link> component prefetch.\nconst pylonGlobal = {\n prefetch,\n navigate,\n // Read by useParams(); a getter so it always reflects the latest nav.\n get params() {\n return currentParams;\n },\n // Read by useRouteSeed(); the seed the active navigation was started with\n // (from <Link seed>), or null. A getter so it always reflects the latest nav.\n get seed() {\n return currentSeed;\n },\n};\nif (typeof window !== \"undefined\") {\n window.__pylon = pylonGlobal;\n}\n";
|
|
85
|
+
export declare const CLIENT_RUNTIME_SOURCE = "// Generated by Pylon SSR (Phase 2 client runtime).\n// DO NOT EDIT \u2014 overwritten on every pylon dev / build.\n\nimport { createElement, Fragment, Suspense } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { configureClient, registerOfflineWorker } from \"@pylonsync/react\";\n// A namespace import for what an older @pylonsync/react may not export, so\n// an app on it still builds (the missing export is undefined).\nimport * as pylonReact from \"@pylonsync/react\";\nimport { createPylonBoundary, nearestBoundaryComponent } from \"./client-boundary\";\nimport { LOADING_MODULES } from \"./loading-registry\";\nimport { createNavPayloadCache } from \"./nav-cache\";\nimport { matchRoute, prefetchTargets } from \"./route-match\";\nimport { clientRouteFor, isOfflinePath } from \"./client-nav\";\nimport { withMetaSlot } from \"./page-tree\";\n\nconst routeCache = Object.create(null);\nlet activeRoot = null;\n// Component path of the route currently mounted (the manifest key). Its cached\n// layout chain is what a pending-navigation skeleton renders inside.\nlet currentComponent = null;\n// Destination of an in-flight client navigation. Read by hydrateRoot's\n// onUncaughtError so a re-render that throws mid-nav degrades to a full page\n// load instead of a white screen. Null when no nav is in flight.\nlet pendingNav = null;\nlet manifestPromise = null;\nconst prefetchedChunks = new Set();\n// The build this page runs (the service worker version from\n// __PYLON_DATA__), or null in an app without `buildManifest({ offline })`.\nlet pageBuild = null;\n\nfunction buildTree(Page, Layouts, props, loadingFor) {\n let tree = createElement(Page, props);\n // The server wraps the page of a route with a loading.tsx in ONE Suspense\n // boundary, fallback = that loading module (ssr-runtime.ts, #278). The\n // tree that hydrates the server HTML must have the same boundary, or React\n // finds <Suspense> where it expects the page's first element, throws a\n // hydration mismatch (#418) and re-renders the whole document; clicks in\n // that window are lost. `loadingFor` is the page's component path.\n const Loading = loadingFor ? nearestLoading(loadingFor) : null;\n if (Loading) {\n tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);\n }\n // The server renders the page's metadata in this slot; the browser leaves\n // it empty. Same shape on both sides, so useId values match (page-tree.ts).\n tree = withMetaSlot(createElement, Fragment, null, tree);\n for (let i = Layouts.length - 1; i >= 0; i--) {\n const Layout = Layouts[i];\n if (!Layout) continue;\n tree = createElement(Layout, props, tree);\n }\n return tree;\n}\n\n// The loading module the server wraps `component` in: the nearest\n// loading.tsx walking up from the page's directory.\nfunction nearestLoading(component) {\n const key = nearestBoundaryComponent(component, \"loading\", Object.keys(LOADING_MODULES));\n return key ? LOADING_MODULES[key] : null;\n}\n\n// ---------------------------------------------------------------------------\n// Client error / not-found boundary. notFound() (from @pylonsync/react) and\n// any other error thrown DURING a client render \u2014 the normal case when an\n// async lookup 404s after hydration \u2014 would otherwise propagate uncaught and\n// blank the whole document. React error boundaries must be class components;\n// this one catches the throw, resolves the nearest not-found.tsx / error.tsx\n// for the active route from the build manifest (the same nearest-ancestor\n// model the server's findBoundary uses), and renders it in its own layout\n// chain. It sits at the root and is transparent until something throws, so\n// hydration still matches the server HTML exactly. It resets on navigation\n// via a changing navEpoch prop (NOT a key \u2014 a key change would remount every\n// layout on each nav and lose their state).\n// ---------------------------------------------------------------------------\n\nlet navEpoch = 0;\n// The props the active page was rendered with (auth, serverData, params, \u2026).\n// The boundary reuses them so a boundary module AND its layout chain \u2014 e.g. an\n// auth-guarding dashboard layout that reads props.auth \u2014 render with the SAME\n// context the page had. The server renders boundaries with the page props too;\n// a minimal hand-built props object would make such a layout see no auth and\n// (for instance) redirect to /login instead of showing the not-found page.\nlet currentPageProps = {};\n\n// Seed data for the in-flight navigation \u2014 the object handed to <Link seed>,\n// exposed to the destination page via useRouteSeed() so it can paint its\n// Suspense fallback with real content before the SSR fetch lands. Set at the\n// start of every navigation (to the seed, or null), so useRouteSeed() reads a\n// value that's consistent for the whole nav. Null on hard load / seedless nav.\nlet currentSeed = null;\n\n// The not-found / error boundary lives in a real module (./client-boundary,\n// emitted next to this file by the bundler) so its render behavior is\n// unit-testable instead of buried in this string. Wire the runtime's\n// internals in as deps; the returned withBoundary(tree, component, navEpoch)\n// wraps every page tree as the transparent root error boundary.\nconst { withBoundary } = createPylonBoundary({\n loadManifest,\n loadRouteEntry,\n navigate,\n buildTree,\n getPageProps: () => currentPageProps,\n getResetHref: () => location.pathname + location.search,\n});\n\n// Deterministic stringify \u2014 MUST match stableStringify in ssr-runtime.ts so\n// a serverData call's cache key is identical on server and client.\nfunction stableStringify(v) {\n if (v === null || v === undefined || typeof v !== \"object\") {\n return JSON.stringify(v === undefined ? null : v);\n }\n if (Array.isArray(v)) return \"[\" + v.map(stableStringify).join(\",\") + \"]\";\n const keys = Object.keys(v).sort();\n return (\n \"{\" +\n keys.map((k) => JSON.stringify(k) + \":\" + stableStringify(v[k])).join(\",\") +\n \"}\"\n );\n}\n\nconst SERVER_DATA_METHODS = [\n \"get\",\n \"list\",\n \"lookup\",\n \"query\",\n \"queryGraph\",\n \"paginate\",\n \"search\",\n];\n\n// A React-recognized fulfilled thenable: use() reads .value synchronously\n// (status === \"fulfilled\") instead of suspending. Critical for hydration \u2014\n// the server streamed the post-Suspense content, so the client must render\n// that content on the FIRST pass without re-suspending (no fallback flash,\n// no mismatch).\nfunction fulfilledThenable(value) {\n return {\n status: \"fulfilled\",\n value,\n then(onFulfilled) {\n return onFulfilled ? onFulfilled(value) : value;\n },\n };\n}\n\n// Client stand-in for the server's serverData handle. Each method returns a\n// pre-fulfilled thenable (cached per key) sourced from the SSR'd ssrData map,\n// keyed identically to the server. Misses yield undefined \u2014 the page\n// rendered with whatever the server fetched, so a hit is expected.\nfunction makeClientServerData(ssrData) {\n const cache = ssrData || {};\n const pc = new Map();\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n // serverData.fn(name, args) \u2014 key MUST match the server's makeServerData\n // (\"fn:\" + name + \":\" + stableStringify(args ?? {})) so hydration replays\n // the query function's SSR'd result without a mismatch.\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n return sd;\n}\n\n// serverData stand-in used during the OPTIMISTIC first paint of a navigation:\n// every method returns a never-resolving thenable, so a page that use()s it\n// suspends and shows its Suspense fallback (which useRouteSeed() renders from\n// the clicked-link seed). The thenable is cached per key so React never sees an\n// \"uncached promise\" if it re-renders the optimistic tree. It never resolves on\n// its own \u2014 navigate() replaces the whole tree with real, fulfilled serverData\n// the moment the SSR fetch lands, which is what actually swaps fallback\u2192content.\nfunction makePendingServerData() {\n const pc = new Map();\n const pending = () => ({ then() {} });\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n return sd;\n}\n\n// Server-only response controller has no meaning on the client (the status/\n// redirect/cookies already shipped). Give pages a no-op so a body that\n// touches props.response during hydration doesn't crash.\nfunction makeNoopResponse() {\n const noop = () => {};\n return {\n setStatus: noop,\n setHeader: noop,\n setCookie: noop,\n redirect: noop,\n notFound: noop,\n };\n}\n\n// Rehydrate the live, server-only props (serverData + response) that were\n// stripped before serialization, so the client tree matches the server's.\n// For a hydrated error boundary (#279), synthesize the reset() the server\n// rendered as a no-op: re-fetch + re-render the current URL (a transient\n// error clears to the page; a deterministic one re-shows the boundary).\n// The current route's dynamic params (e.g. { projectId: \"p_1\" }). Lives here,\n// not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()\n// in a deep client child has a reactive source. A fresh object per nav \u2192 stable\n// reference between navs (useSyncExternalStore needs that).\nlet currentParams = {};\nfunction setNavParamsRaw(params) {\n currentParams = params || {};\n}\nfunction setNavParams(data) {\n setNavParamsRaw(data && data.props && data.props.params);\n}\n\nfunction withClientProps(data) {\n const props = { ...(data.props || {}) };\n props.serverData = makeClientServerData(data.ssrData);\n props.response = makeNoopResponse();\n if (data.kind === \"error\") {\n props.reset = function () {\n navigate(location.pathname + location.search, { replace: true });\n };\n }\n return props;\n}\n\nfunction readPylonData() {\n const dataEl = document.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) return null;\n try {\n return JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n return null;\n }\n}\n\n// The build manifest of this page's build. A manifest of another build is\n// not used: its route files cannot run next to this page's (two copies of\n// React, a layout from each build). That happens when an older service\n// worker answers from its cache, or when the server already runs a newer\n// build. Navigation then moves the page to the server's build\n// (moveToLatestBuild). A failed fetch is not kept, so the next call asks\n// again.\nasync function loadManifest() {\n if (!manifestPromise) {\n const pending = fetch(\"/_pylon/build/manifest.json\", {\n credentials: \"same-origin\",\n })\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null)\n .then((manifest) => {\n if (!manifest && manifestPromise === pending) manifestPromise = null;\n const build = manifest && manifest.offline && manifest.offline.version;\n if (build && pageBuild && build !== pageBuild) {\n console.warn(\n \"[pylon ssr] the build manifest is for build \" + build +\n \", and this page runs build \" + pageBuild + \". Not using it.\",\n );\n return null;\n }\n return manifest;\n });\n manifestPromise = pending;\n }\n return manifestPromise;\n}\n\n// Move this page to the server's build when it runs another one (see\n// moveToLatestBuild in @pylonsync/react): the outbox is sent first, the\n// waiting service worker takes over, and `url` loads once. Resolves with\n// what happened: \"moving\", \"current\", \"offline\", \"pending-writes\",\n// \"blocked\", or \"inert\" (no offline build, or an older @pylonsync/react).\nfunction moveToLatestBuild(options) {\n const move = pylonReact.moveToLatestBuild;\n if (typeof move !== \"function\") return Promise.resolve(\"inert\");\n return move(options).catch((e) => {\n console.warn(\"[pylon ssr] could not move to the new build:\", e);\n return \"inert\";\n });\n}\n\n// A route entry failed to load, or the server answered with a page of\n// another build. True when navigate() is done: the page is loading the\n// server's build, or the device is offline and the page keeps its screen.\n// False when the caller falls back to a full page load (the server runs\n// this build, or the page already moved and is still on it).\nasync function movedForFailedRoute(href) {\n const result = await moveToLatestBuild({ url: href, recheck: true });\n if (result === \"offline\") {\n console.warn(\"[pylon ssr] offline: \" + href + \" cannot open now.\");\n }\n return result === \"moving\" || result === \"offline\";\n}\n\nfunction preloadChunks(routeInfo) {\n if (!routeInfo) return;\n const prefix = routeInfo.public_prefix || \"/_pylon/build/\";\n const all = [routeInfo.file, ...(routeInfo.imports || [])];\n for (const c of all) {\n // prefetch() passes file: \"\" (HTML-only prefetch has no route entry);\n // an empty name would modulepreload the bare prefix (\"/_pylon/build/\")\n // and the host answers that with a 400 on every page load.\n if (!c) continue;\n if (prefetchedChunks.has(c)) continue;\n prefetchedChunks.add(c);\n const link = document.createElement(\"link\");\n link.rel = \"modulepreload\";\n link.href = prefix + c;\n document.head.appendChild(link);\n }\n}\n\n// Run fn once the page itself has finished loading. modulepreload fetches at\n// high priority, and a sidebar of links warms on first paint (Link's\n// IntersectionObserver), so warming a dozen route chunks inline would compete\n// with the current page's own scripts and data \u2014 buying a faster second\n// navigation with a slower first render.\nfunction whenLoaded(fn) {\n if (document.readyState === \"complete\") {\n fn();\n return;\n }\n window.addEventListener(\"load\", fn, { once: true });\n}\n\n// Run fn when the browser is idle after the page loaded, so warming the next\n// route never competes with this one.\nfunction whenIdle(fn) {\n whenLoaded(() => {\n if (typeof window.requestIdleCallback === \"function\") {\n window.requestIdleCallback(fn, { timeout: 2000 });\n } else {\n setTimeout(fn, 200);\n }\n });\n}\n\n// Import a client-navigable route's entry ahead of the click, so navigate()\n// finds it in routeCache and renders in the same frame. Only after the first\n// hydration: an entry that loads before it would be taken for the page.\nfunction warmRouteEntry(component) {\n if (!activeRoot || routeCache[component]) return;\n loadRouteEntry(component).catch(() => {});\n}\n\n// Marks a request as a client-side NAVIGATION rather than a document load.\n// The server answers with the head metadata + __PYLON_DATA__ and skips the\n// page markup, which this runtime re-renders from that data anyway. The host\n// keys its render cache on the same header, so the two shapes never share an\n// entry. Prefetch and navigate MUST send identical headers, or the prefetched\n// response is the wrong shape for the click that consumes it.\nconst NAV_REQUEST_HEADERS = { Accept: \"text/html\", \"X-Pylon-Nav\": \"1\" };\n\n// Payloads warmed by a hover, consumed by the click that follows. See\n// ./nav-cache for why this is in memory rather than an HTTP-level prefetch.\nconst navPayloads = createNavPayloadCache({\n now: () => Date.now(),\n fetchPage: (target) =>\n fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n }).then((res) => {\n // A redirect means the URL navigate() would commit isn't the one that\n // answered; let the real navigation resolve that itself.\n if (!res.ok || res.redirected) return null;\n return res.text();\n }),\n});\n\nasync function prefetch(href, opts) {\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) return;\n const manifest = await loadManifest();\n // A route that renders from the client never fetches its page, so there is\n // no payload to warm. Its entry is warmed instead, below.\n const clientRoute = clientRouteFor(manifest, url.pathname, matchRoute);\n // The page payload, but ONLY on a real intent signal (hover / touch). It\n // costs a full SSR render, so warming a screenful of links on sight would\n // spend a dozen renders per page load to save one.\n if (opts && opts.document && !clientRoute) {\n navPayloads.prefetch(url.pathname + url.search);\n }\n if (!manifest) return;\n // Chunk prefetch. The page payload above is the half that was already\n // cheap: nothing can render until the destination's own entry chunk lands,\n // so warming only the HTML leaves a full round-trip on the click.\n const targets = prefetchTargets(manifest, url.pathname);\n const warm = () => {\n preloadChunks({\n public_prefix: manifest.public_prefix,\n file: targets.file,\n imports: targets.imports,\n });\n if (clientRoute) warmRouteEntry(clientRoute.component);\n };\n // Intent (hover, touch, pointerdown, focus): the tap is coming, fetch now.\n // Otherwise (a link on screen) wait until the page has painted and the\n // browser is idle, so warming routes never counts against this page's\n // first paint, and skip it on a connection the user asked to save.\n if (opts && opts.document) warm();\n else if (!saveData()) whenIdle(warm);\n}\n\n// The user asked for less data (Save-Data), or the connection is 2G.\n// Prefetch on sight costs bytes the user may never use.\nfunction saveData() {\n const c = typeof navigator !== \"undefined\" ? navigator.connection : undefined;\n if (!c) return false;\n return c.saveData === true || /(^|-)2g$/.test(c.effectiveType || \"\");\n}\n\nasync function loadRouteEntry(component) {\n if (routeCache[component]) return routeCache[component];\n const manifest = await loadManifest();\n if (!manifest) throw new Error(\"manifest unavailable\");\n const routeInfo = manifest.routes[component];\n if (!routeInfo) throw new Error(\"unknown component \" + component);\n const prefix = manifest.public_prefix || \"/_pylon/build/\";\n preloadChunks({ ...routeInfo, public_prefix: prefix });\n // Dynamic import the entry. It will call hydrate(...) which\n // populates routeCache[component], then this returns.\n await import(/* @vite-ignore */ prefix + routeInfo.file);\n if (!routeCache[component]) {\n throw new Error(\"route entry did not register: \" + component);\n }\n return routeCache[component];\n}\n\nexport function hydrate(component, Page, Layouts, metadata) {\n // Always cache the route for nav. `metadata` is the page's static\n // `metadata` export: a navigation with no page fetch takes its title.\n routeCache[component] = { Page, Layouts, metadata };\n // Start the manifest fetch NOW rather than on first use. Nothing can resolve\n // a route without it \u2014 the first <Link> reaching the viewport warms its\n // chunks only after it lands \u2014 so leaving it lazy put a round trip in front\n // of all prefetching. In flight from here, it resolves during hydration.\n void loadManifest();\n const data = readPylonData();\n // Namespace client storage by the app's manifest name (injected into\n // __PYLON_DATA__ as `app`) BEFORE any db hook / auth helper reads a token.\n // Without this every app on a shared origin (all localhost:4321 in dev)\n // shares the default `pylon_token` and clobbers each other's sessions.\n // configureClient runs once here; it also migrates a legacy default-keyspace\n // token so an existing session survives the switch.\n if (data && data.app) configureClient({ appName: data.app });\n // First hydrate: the entry's component MATCHES the SSR'd page.\n // Establish the root + install the click + popstate handlers\n // exactly once.\n if (!activeRoot) {\n if (!data || data.component !== component) {\n console.warn(\n \"[pylon ssr] entry/__PYLON_DATA__ mismatch \u2014 initial hydration skipped\",\n );\n return;\n }\n pageBuild = (data.offline && data.offline.version) || null;\n setNavParams(data);\n currentPageProps = withClientProps(data);\n currentComponent = data.component;\n const tree = withBoundary(\n buildTree(Page, Layouts, currentPageProps, data.component),\n data.component,\n navEpoch,\n );\n activeRoot = hydrateRoot(document, tree, {\n // Safety net for client navigation. If a nav re-render throws an uncaught\n // error in React's commit phase, the URL has already changed but the page\n // can't swap \u2014 a white/stale screen. The classic trigger is a page that\n // renders hoisted <title>/<meta>/<link> in its own tree (use the\n // `metadata` export instead); React 19 owns those head nodes on the client\n // and reconciling them across routes can throw. Rather than strand the\n // user, fall back to a full page load of the pending destination, which\n // re-renders it cleanly from SSR. Non-navigation errors keep React's\n // default reporting.\n onUncaughtError(error) {\n if (pendingNav) {\n const dest = pendingNav;\n pendingNav = null;\n console.error(\n \"[pylon ssr] client navigation failed to render; falling back to a full page load:\",\n error,\n );\n window.location.href = dest;\n return;\n }\n console.error(error);\n },\n });\n installNavHandlers();\n // Registers the offline service worker for an app that opted in, and\n // removes one left on this origin by an earlier build otherwise.\n // `build` is the build this page runs, for useAppUpdate. An option\n // rather than its own import, so an app on an older @pylonsync/react\n // still builds.\n registerOfflineWorker({\n optedIn: !!data.offline,\n production: !(data.offline && data.offline.dev),\n signedIn: !!(data.props && data.props.session && data.props.session.exists),\n build:\n data.offline && data.offline.version\n ? { version: data.offline.version, builtAt: data.offline.builtAt || null }\n : undefined,\n });\n if (data.offline) {\n // The service worker answered this load with the page it kept for\n // another URL: a dynamic offline route, or no network. The page on\n // screen is that URL's; render the address bar's URL from the replica.\n const shellPath = data.props && data.props.pathname;\n if (typeof shellPath === \"string\" && shellPath !== location.pathname) {\n navigate(location.pathname + location.search + location.hash, {\n replace: true,\n shellPath,\n });\n }\n }\n return;\n }\n // Subsequent hydrate calls fire from dynamic-loaded entries\n // during navigation. The render is driven by navigate(), so we\n // only need to populate the cache (already done above).\n}\n\n// Swap the page's SEO/social <head> tags on a client-side navigation.\n// The SSR runtime marks every page-metadata <meta>/<link> with\n// data-pylon-meta; we drop the current set and import the incoming page's\n// set, so description / canonical / og:* / twitter:* / icons track the new\n// route. The layout's charset/viewport and Pylon's injected stylesheet\n// links carry no marker, so they survive untouched (no FOUC). The page\n// component never renders these tags on the client, so React doesn't own\n// them \u2014 this manual swap can't fight hydration.\nfunction syncHeadMeta(doc) {\n const head = document.head;\n if (!head) return;\n const current = head.querySelectorAll(\"[data-pylon-meta]\");\n for (let i = 0; i < current.length; i++) current[i].remove();\n const incoming = doc.head\n ? doc.head.querySelectorAll(\"[data-pylon-meta]\")\n : [];\n for (let i = 0; i < incoming.length; i++) {\n head.appendChild(document.importNode(incoming[i], true));\n }\n}\n\n// How long a navigation may run before the destination's loading.tsx takes\n// over the page area. Below this, the transition finishes on its own and\n// swapping to a skeleton would read as a flicker rather than as feedback.\nconst PENDING_PLACEHOLDER_MS = 100;\n\n// Paint the destination's nearest loading.tsx in place of the current page,\n// so a click that can't complete immediately is still acknowledged. Returns\n// whether anything was rendered \u2014 apps without a loading.tsx keep the old\n// behavior (previous page stays put until the new one is ready).\n//\n// Rendered inside the CURRENTLY MOUNTED layout chain, not the destination's:\n// the destination's layouts live in the chunk this skeleton exists to cover,\n// so waiting for them would defeat the point. Sibling routes (the common case\n// for a nav that's slow enough to reach here) share that chain anyway, and\n// React keeps those layouts mounted across the swap \u2014 only the page area\n// changes.\n//\n// Props are the current page's, verbatim. Layouts re-render with the data they\n// already had, so one that read serverData or params can't suspend on a\n// never-resolving handle or crash on a cache miss halfway through a\n// transition. A skeleton has no data of its own to show.\nfunction renderPendingPlaceholder(pathname, manifest, myEpoch) {\n if (!activeRoot || !currentComponent) return false;\n const matched = matchRoute(manifest, pathname);\n if (!matched) return false;\n const key = nearestBoundaryComponent(\n matched.component,\n \"loading\",\n Object.keys(LOADING_MODULES),\n );\n const Loading = key ? LOADING_MODULES[key] : null;\n if (!Loading) return false;\n const current = routeCache[currentComponent];\n const tree = withBoundary(\n buildTree(Loading, (current && current.Layouts) || [], currentPageProps),\n currentComponent,\n myEpoch,\n );\n activeRoot.render(tree);\n return true;\n}\n\n// The path and query the page last rendered. A popstate that keeps them and\n// only changes the hash is a same-page jump (an anchor link, a back press\n// between anchors), not a navigation: re-rendering for it would reset the\n// scroll to the top.\nlet renderedPath = \"\";\n\nasync function navigate(href, opts) {\n const push = !opts || opts.push !== false;\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) {\n window.location.href = href;\n return;\n }\n const target = url.pathname + url.search;\n\n // Claim an epoch for this navigation. A later navigate() bumps navEpoch, so\n // every await below can bail once it's been superseded by a newer nav \u2014\n // otherwise a slow fetch could render a stale destination over a newer one.\n const myEpoch = ++navEpoch;\n currentSeed = opts && opts.seed != null ? opts.seed : null;\n\n // Flipped the moment this nav commits or gives up, so a pending-placeholder\n // timer that fires late can never paint a skeleton over real content.\n const navState = { settled: false };\n\n // ---- New build -----------------------------------------------------------\n // The last check found a newer build on the server: open the link in it\n // instead of rendering it with this page's build, whose files the deploy\n // removed. Not while writes wait to be sent; a failure below moves then.\n if (activeRoot) {\n const early = await moveToLatestBuild({ url: href, skipIfPendingWrites: true });\n if (early === \"moving\" || myEpoch !== navEpoch) return;\n }\n\n // ---- Instant navigation --------------------------------------------------\n // A route whose page exports `navigation = \"client\"`, or whose URL is an\n // offline route, renders from the client with no page fetch: its data comes\n // from the local replica. The manifest is in hand after hydration, and a\n // Link warms the entry ahead of the click, so this paints in the same frame.\n if (activeRoot) {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = clientRouteFor(manifest, url.pathname, matchRoute);\n if (matched) {\n let route = null;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n // After a deploy: move to the new build. Offline: keep the screen.\n // Otherwise fall through to the fetch below, which reloads the page\n // when the entry cannot load.\n if (myEpoch !== navEpoch) return;\n if (await movedForFailedRoute(href)) return;\n console.warn(\"[pylon ssr] client navigation fell back to a page fetch:\", e);\n }\n if (myEpoch !== navEpoch) return;\n if (route) {\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return;\n }\n }\n }\n const fullLoad = () => {\n navState.settled = true;\n window.location.href = href;\n };\n\n // ---- Optimistic first paint --------------------------------------------\n // With a seed AND a client-resolvable route, render the destination NOW \u2014\n // before the SSR fetch \u2014 with a pending serverData, so the page shows its\n // Suspense fallback painted from the seed (via useRouteSeed). The real render\n // below swaps in full data in place. Any failure (no route match, chunk load\n // error) falls through to the normal blocking fetch-then-render path, so a\n // seed can never break navigation. Requires the page to keep its serverData\n // reads inside a <Suspense> (the store detail page does) \u2014 a bare top-level\n // use() would suspend the whole page with no seeded fallback to show.\n let didOptimistic = false;\n if (currentSeed != null && activeRoot) {\n try {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = matchRoute(manifest, url.pathname);\n if (matched) {\n const route = await loadRouteEntry(matched.component);\n if (myEpoch !== navEpoch) return;\n setNavParamsRaw(matched.params);\n currentPageProps = {\n params: matched.params,\n serverData: makePendingServerData(),\n response: makeNoopResponse(),\n };\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n matched.component,\n myEpoch,\n );\n pendingNav = target;\n currentComponent = matched.component;\n activeRoot.render(tree);\n if (opts && opts.replace) {\n history.replaceState({ component: matched.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: matched.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n didOptimistic = true;\n }\n } catch (e) {\n // Fall through to the normal fetch-then-render path below.\n }\n }\n\n // ---- Pending-state placeholder -----------------------------------------\n // Nothing below paints until the SSR payload AND the destination's chunk are\n // both in hand, which on a cold route is long enough for the click to look\n // ignored. Hand the page area to the destination's loading.tsx once the nav\n // outlives the flicker threshold. Skipped when a seed already painted the\n // real destination \u2014 that's strictly better than a skeleton.\n let placeholderTimer = null;\n if (!didOptimistic && activeRoot) {\n placeholderTimer = setTimeout(() => {\n placeholderTimer = null;\n // loadManifest resolves instantly once warm (Link prefetch primes it on\n // first paint); the settled check covers the cold first navigation,\n // where the fetch can win the race with it.\n loadManifest().then((manifest) => {\n if (navState.settled || myEpoch !== navEpoch) return;\n if (renderPendingPlaceholder(url.pathname, manifest, myEpoch)) {\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n }\n });\n }, PENDING_PLACEHOLDER_MS);\n }\n\n // ---- Real fetch + render -----------------------------------------------\n let html;\n try {\n // A hover-prefetch usually has this in hand, or in flight \u2014 either way the\n // click joins it instead of opening a second request for the same page.\n const prefetched = navPayloads.take(target);\n html = prefetched ? await prefetched : null;\n if (html == null) {\n const res = await fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n });\n if (!res.ok) {\n fullLoad();\n return;\n }\n html = await res.text();\n }\n } catch {\n // No network. An offline route renders from the replica; anything else\n // needs the server.\n if (myEpoch !== navEpoch) return;\n if (await renderOffline(url, myEpoch, opts, push)) {\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n return;\n }\n if (opts && opts.shellPath) {\n // This load already came from a kept page and the URL cannot open\n // offline. Reloading would get the same kept page again; show its URL.\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n history.replaceState({ component: currentComponent }, \"\", opts.shellPath);\n renderedPath = location.pathname + location.search;\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n return;\n }\n fullLoad();\n return;\n }\n if (myEpoch !== navEpoch) return;\n const doc = new DOMParser().parseFromString(html, \"text/html\");\n const dataEl = doc.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) {\n fullLoad();\n return;\n }\n let data;\n try {\n data = JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n fullLoad();\n return;\n }\n // The server runs another build: this page's route files cannot render\n // its pages.\n const otherBuild =\n pageBuild && data.offline && data.offline.version && data.offline.version !== pageBuild;\n let route = null;\n if (!otherBuild) {\n try {\n route = await loadRouteEntry(data.component);\n } catch (e) {\n console.warn(\"[pylon ssr] nav fallback (entry load failed):\", e);\n }\n }\n if (myEpoch !== navEpoch) return;\n if (!route) {\n if (await movedForFailedRoute(href)) {\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n return;\n }\n if (myEpoch !== navEpoch) return;\n fullLoad();\n return;\n }\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n document.title = doc.title || document.title;\n syncHeadMeta(doc);\n setNavParams(data);\n // Keep currentSeed set for the rest of this nav: useRouteData renders the seed\n // as CONTENT (not a Suspense fallback) and upgrades it to real data in place,\n // so the seed must stay readable across the optimistic\u2192real render. It's reset\n // at the START of the next navigation. (Clearing it here would degrade the\n // page to its skeleton for a frame during the swap \u2014 a visible flash.)\n currentPageProps = withClientProps(data);\n // Reuse myEpoch (do NOT bump) so the optimistic tree and the real tree share\n // a boundary identity: React resolves the Suspense fallback into the real\n // content in place instead of remounting the whole subtree (no flash).\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n // Track the in-flight destination so hydrateRoot's onUncaughtError can fall\n // back to a full page load if this re-render throws in React's commit phase\n // (instead of leaving the URL changed but the page unswapped). Cleared on the\n // next macrotask once the commit has settled with no error.\n pendingNav = target;\n currentComponent = data.component;\n // Drop every other prefetched payload once a navigation commits: they were\n // rendered against the previous page's state, and whatever the user does\n // here can invalidate them. Hovering on the new page re-warms in one request.\n navPayloads.clear();\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (didOptimistic) {\n // URL + scroll already handled during the optimistic paint; only keep the\n // history entry's component in sync with the authoritative SSR component.\n history.replaceState({ component: data.component }, \"\", target);\n } else {\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n // Notify the router hooks (useSearchParams / usePathname) so deep children\n // re-read location after a Link click or a router.push(). popstate already\n // covers back/forward, but pushState/replaceState fire no event.\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n // After a successful nav, scroll to top (Next.js default).\n window.scrollTo(0, 0);\n }\n}\n\n// Render an offline route with no server, after the page fetch failed: the\n// page reads its data from the local replica (db.useQuery), so it needs only\n// its code, its params and the identity-free props the last page had. Returns\n// false when the URL is not an offline route, or its code is not on the\n// device.\nasync function renderOffline(url, myEpoch, opts, push) {\n const manifest = await loadManifest();\n if (!manifest || !activeRoot) return false;\n if (!isOfflinePath(manifest, url.pathname, matchRoute)) return false;\n const matched = matchRoute(manifest, url.pathname);\n if (!matched) return false;\n let route;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n return false;\n }\n if (myEpoch !== navEpoch) return true;\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return true;\n}\n\n// Commit a navigation rendered from the client (instant navigation, or an\n// offline route with no network). History, the navigation event and the\n// scroll reset are the same as a fetched navigation's.\nfunction renderFromClient(url, matched, route, myEpoch, opts, push) {\n const prev = currentPageProps || {};\n const searchParams = {};\n url.searchParams.forEach((v, k) => {\n searchParams[k] = v;\n });\n const data = {\n component: matched.component,\n props: {\n pathname: url.pathname,\n url: url.pathname,\n host: prev.host || \"\",\n params: matched.params,\n searchParams,\n auth: prev.auth,\n session: prev.session,\n headers: {},\n cookies: {},\n },\n ssrData: {},\n };\n setNavParams(data);\n currentPageProps = withClientProps(data);\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n const target = url.pathname + url.search + url.hash;\n // See the fetched path: a commit that throws falls back to a page load.\n pendingNav = target;\n currentComponent = data.component;\n navPayloads.clear();\n const title = route.metadata && route.metadata.title;\n if (typeof title === \"string\" && title) document.title = title;\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n}\n\nfunction installNavHandlers() {\n document.addEventListener(\"click\", (e) => {\n if (e.defaultPrevented) return;\n if (e.button !== 0) return;\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const target = e.target;\n if (!target || !target.closest) return;\n const link = target.closest(\"a[data-pylon-link]\");\n if (!link) return;\n const href = link.getAttribute(\"href\");\n if (!href) return;\n if (link.target && link.target !== \"\" && link.target !== \"_self\") return;\n if (href.startsWith(\"http://\") || href.startsWith(\"https://\") || href.startsWith(\"//\")) {\n // External \u2014 let the browser handle.\n return;\n }\n e.preventDefault();\n // A <Link seed> registers its seed in window.__pylonLinkSeeds keyed by the\n // anchor element; pass it through so navigate() can paint optimistically.\n const seeds =\n typeof window !== \"undefined\" ? window.__pylonLinkSeeds : undefined;\n const seed = seeds ? seeds.get(link) : undefined;\n navigate(href, seed != null ? { seed } : undefined);\n });\n renderedPath = location.pathname + location.search;\n window.addEventListener(\"popstate\", () => {\n const next = location.pathname + location.search;\n if (next === renderedPath) return;\n navigate(next, { push: false });\n });\n // Progressive-enhancement form submit (#276): intercept <form data-pylon-form>\n // so the page doesn't full-reload. fetch the same route.ts endpoint, follow\n // the handler's redirect, and swap the destination in via navigate(). Without\n // JS this listener never runs and the browser submits natively (POST \u2192\n // handler \u2192 303 \u2192 GET) \u2014 identical result, just a full navigation.\n document.addEventListener(\"submit\", (e) => {\n if (e.defaultPrevented) return;\n const form = e.target;\n if (!form || form.tagName !== \"FORM\") return;\n if (!form.hasAttribute(\"data-pylon-form\")) return;\n const action = form.getAttribute(\"action\");\n if (!action) return;\n // Off-origin actions / new-tab targets \u2192 let the browser submit.\n if (action.startsWith(\"http://\") || action.startsWith(\"https://\") || action.startsWith(\"//\")) return;\n const tgt = form.getAttribute(\"target\");\n if (tgt && tgt !== \"\" && tgt !== \"_self\") return;\n const method = (form.getAttribute(\"method\") || \"post\").toUpperCase();\n e.preventDefault();\n // urlencoded body (matches the server's parser; files use the native path).\n const body = new URLSearchParams();\n const fd = new FormData(form);\n fd.forEach((v, k) => {\n if (typeof v === \"string\") body.append(k, v);\n });\n fetch(action, {\n method,\n body,\n credentials: \"same-origin\",\n headers: { Accept: \"text/html\" },\n })\n .then((res) => {\n // fetch followed the handler's 303 \u2192 res.url is the destination page.\n // Drive a client navigation to it (re-renders without a full reload).\n const dest = new URL(res.url || action, location.href);\n if (dest.origin === location.origin) {\n navigate(dest.pathname + dest.search);\n } else {\n window.location.href = dest.href;\n }\n })\n .catch(() => {\n // Network/abort \u2014 fall back to a native submit so the user isn't stuck.\n form.removeAttribute(\"data-pylon-form\");\n form.submit();\n });\n });\n}\n\n// Expose for <Link> component prefetch.\nconst pylonGlobal = {\n prefetch,\n navigate,\n // Read by useParams(); a getter so it always reflects the latest nav.\n get params() {\n return currentParams;\n },\n // Read by useRouteSeed(); the seed the active navigation was started with\n // (from <Link seed>), or null. A getter so it always reflects the latest nav.\n get seed() {\n return currentSeed;\n },\n};\nif (typeof window !== \"undefined\") {\n window.__pylon = pylonGlobal;\n}\n";
|
|
86
86
|
/**
|
|
87
87
|
* Write the client runtime (`client-runtime.ts`) and the sibling modules it
|
|
88
88
|
* imports into `stageDir`. Each sibling's source of truth is this package's
|
package/dist/ssr-page-tree.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
type CreateElement = (type: any, props: any, ...children: any[]) => any;
|
|
2
|
-
/** The page with its metadata slot.
|
|
2
|
+
/** The page with its metadata slot. Both sides pass null for `meta`. */
|
|
3
3
|
export declare function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any;
|
|
4
4
|
export {};
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -217,11 +217,11 @@ export declare function finalizeHeaders(state: ResponseState, extra?: Record<str
|
|
|
217
217
|
* (static) or `export async function generateMetadata(props)` (dynamic,
|
|
218
218
|
* e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
|
|
219
219
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
220
|
+
* A layout can export the same; the page's values win (`mergeMetadata`).
|
|
221
|
+
* The resulting <title>/<meta>/<link> are written into <head> by the server,
|
|
222
|
+
* whatever the layouts render. A tag from the metadata replaces a tag the
|
|
223
|
+
* root layout writes in its own <head> for the same title, `name`,
|
|
224
|
+
* `property` or canonical link (`dropReplacedHeadTags`).
|
|
225
225
|
*/
|
|
226
226
|
/** A single OpenGraph image (for `openGraph.images` — multiple images). */
|
|
227
227
|
export interface OgImage {
|
|
@@ -315,9 +315,9 @@ export interface SsrMetadata {
|
|
|
315
315
|
}
|
|
316
316
|
/**
|
|
317
317
|
* Build a React fragment of <title>/<meta>/<link> from a page's metadata.
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
318
|
+
* The server renders it to HTML on its own and splices it in before
|
|
319
|
+
* </head>. React escapes all text/attrs, so there's no manual XSS
|
|
320
|
+
* handling. Returns null when there's nothing to emit.
|
|
321
321
|
*/
|
|
322
322
|
export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
|
|
323
323
|
/**
|
|
@@ -334,6 +334,26 @@ export declare function renderMetadata(React: any, m: SsrMetadata | undefined):
|
|
|
334
334
|
* element. JSON.parse reads them back, so the data stays valid.
|
|
335
335
|
*/
|
|
336
336
|
export declare function renderJsonLdHead(m: SsrMetadata | undefined): string;
|
|
337
|
+
/**
|
|
338
|
+
* The metadata a route renders with: each layout's, root first, then the
|
|
339
|
+
* page's. A later value replaces an earlier one. `openGraph`, `twitter`,
|
|
340
|
+
* `icons` and `alternates` merge field by field. A new `openGraph.image` or
|
|
341
|
+
* `images` drops the earlier image fields, and a new `twitter.image` drops
|
|
342
|
+
* the earlier `twitter.imageAlt`: they describe the other image.
|
|
343
|
+
*/
|
|
344
|
+
export declare function mergeMetadata(parent: SsrMetadata | undefined, child: SsrMetadata | undefined): SsrMetadata | undefined;
|
|
345
|
+
/** The keys (see `headTagKey`) that the tags in `html` set. */
|
|
346
|
+
export declare function headTagKeys(html: string): Set<string>;
|
|
347
|
+
/**
|
|
348
|
+
* Remove from `head` (the document up to `</head>`) the tags that the page
|
|
349
|
+
* metadata replaces: a <title>, a <meta> with the same `name` or `property`,
|
|
350
|
+
* and a canonical link. A new `og:image` or `twitter:image` also removes the
|
|
351
|
+
* old image's `og:image:*` or `twitter:image:*` tags. So a layout can write
|
|
352
|
+
* default tags in its own <head>, and a page's metadata replaces them.
|
|
353
|
+
*/
|
|
354
|
+
export declare function dropReplacedHeadTags(head: string, replacing: Set<string>): string;
|
|
355
|
+
/** The first <title> element in `html`, or "". */
|
|
356
|
+
export declare function firstTitleTag(html: string): string;
|
|
337
357
|
/**
|
|
338
358
|
* Import a project-relative module, trying each common extension. A
|
|
339
359
|
* production server bundle serves it from the bundle's module registry.
|
|
@@ -417,7 +437,7 @@ isBoundary?: boolean): SsrMetadata | undefined;
|
|
|
417
437
|
* and corrupt on an unrelated CSS change. A streaming decoder holds the
|
|
418
438
|
* partial sequence back until the bytes that complete it arrive.
|
|
419
439
|
*/
|
|
420
|
-
export declare function streamWithHeadInjection(reader: ReadableStreamDefaultReader<Uint8Array>, headBlob: string, sendChunk: (text: string) => void, bodyBlob?: string): Promise<void>;
|
|
440
|
+
export declare function streamWithHeadInjection(reader: ReadableStreamDefaultReader<Uint8Array>, headBlob: string, sendChunk: (text: string) => void, bodyBlob?: string, rewriteHead?: (head: string) => string): Promise<void>;
|
|
421
441
|
/**
|
|
422
442
|
* Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
|
|
423
443
|
* mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
|
package/package.json
CHANGED
package/src/offline-sw.test.ts
CHANGED
|
@@ -44,7 +44,9 @@ describe("classifyRequest", () => {
|
|
|
44
44
|
|
|
45
45
|
test("build files are cache-first, same-origin or on the CDN", () => {
|
|
46
46
|
expect(classifyRequest(req("/_pylon/build/chunks/a-123.js"), CONFIG, ORIGIN)).toBe("build");
|
|
47
|
-
expect(classifyRequest(req("/_pylon/build/manifest.json"), CONFIG, ORIGIN)).toBe(
|
|
47
|
+
expect(classifyRequest(req("/_pylon/build/manifest.json"), CONFIG, ORIGIN)).toBe(
|
|
48
|
+
"build-manifest",
|
|
49
|
+
);
|
|
48
50
|
const cdn = { ...CONFIG, buildPrefix: "https://assets.example/b1/" };
|
|
49
51
|
expect(classifyRequest(req("https://assets.example/b1/x-123.js"), cdn, ORIGIN)).toBe("build");
|
|
50
52
|
expect(classifyRequest(req("https://assets.example/other.js"), cdn, ORIGIN)).toBe("network");
|
|
@@ -303,6 +305,39 @@ describe("build files and other requests", () => {
|
|
|
303
305
|
expect(await text(env.dispatch("fetch", { request: req(lazy) }))).toBe(`file ${lazy}`);
|
|
304
306
|
});
|
|
305
307
|
|
|
308
|
+
test("the build manifest comes from the server, so a newer build's page gets its own", async () => {
|
|
309
|
+
// This worker kept build 1's manifest at install. After a deploy, a page
|
|
310
|
+
// of build 2 loaded from the network asks for the manifest: it must get
|
|
311
|
+
// build 2's, not the kept copy, or every route entry it names is gone.
|
|
312
|
+
let manifest = "manifest build 1";
|
|
313
|
+
let online = true;
|
|
314
|
+
const env = makeScope(async (url, init) => {
|
|
315
|
+
if (!online) return offline();
|
|
316
|
+
if (new URL(url).pathname === "/_pylon/build/manifest.json") return new Response(manifest);
|
|
317
|
+
return onlineServer()(url, init);
|
|
318
|
+
});
|
|
319
|
+
await env.dispatch("install");
|
|
320
|
+
manifest = "manifest build 2";
|
|
321
|
+
const request = req("/_pylon/build/manifest.json");
|
|
322
|
+
expect(await text(env.dispatch("fetch", { request }))).toBe("manifest build 2");
|
|
323
|
+
// Offline: the last copy the network gave.
|
|
324
|
+
online = false;
|
|
325
|
+
expect(await text(env.dispatch("fetch", { request }))).toBe("manifest build 2");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a slow network gives way to the kept build manifest", async () => {
|
|
329
|
+
let slow = false;
|
|
330
|
+
const env = makeScope(async (url, init) => {
|
|
331
|
+
if (slow) return new Promise<Response>((r) => setTimeout(() => r(new Response("late")), 500));
|
|
332
|
+
return onlineServer()(url, init);
|
|
333
|
+
});
|
|
334
|
+
await env.dispatch("install");
|
|
335
|
+
slow = true;
|
|
336
|
+
expect(
|
|
337
|
+
await text(env.dispatch("fetch", { request: req("/_pylon/build/manifest.json") })),
|
|
338
|
+
).toBe("file /_pylon/build/manifest.json");
|
|
339
|
+
});
|
|
340
|
+
|
|
306
341
|
test("API and sync requests are left to the network", async () => {
|
|
307
342
|
const env = makeScope(onlineServer());
|
|
308
343
|
for (const path of ["/api/sync/pull", "/api/auth/session", "/api/entities/Lead"]) {
|
package/src/offline-sw.ts
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
// route's shell, else the start URL's shell. The client runtime renders
|
|
21
21
|
// the real URL from the local replica.
|
|
22
22
|
// - build files: cache first. They are content-hashed, so a cached copy
|
|
23
|
-
// is always correct.
|
|
23
|
+
// is always correct. The one exception is `/_pylon/build/manifest.json`,
|
|
24
|
+
// which has the same URL in every build: network first (with the
|
|
25
|
+
// navigation timeout), the cached copy only when the network fails. A
|
|
26
|
+
// cached copy from this worker's build, given to a page of a newer build,
|
|
27
|
+
// sends the page to route files that no longer exist.
|
|
24
28
|
// - everything else (API, auth, sync, dev endpoints): not intercepted, never
|
|
25
29
|
// cached. Data comes from the sync engine, not the HTTP cache.
|
|
26
30
|
// - activate: delete caches from other builds. A new deploy installs a new
|
|
@@ -60,6 +64,8 @@ export type RequestKind =
|
|
|
60
64
|
| "navigate"
|
|
61
65
|
/** A file of the client build: cache first. */
|
|
62
66
|
| "build"
|
|
67
|
+
/** The build manifest: network first, the cached copy when offline. */
|
|
68
|
+
| "build-manifest"
|
|
63
69
|
/** The web app manifest or an icon: cache first. */
|
|
64
70
|
| "precached"
|
|
65
71
|
/** Not intercepted, never cached. */
|
|
@@ -102,6 +108,7 @@ export function classifyRequest(
|
|
|
102
108
|
}
|
|
103
109
|
if (path === "/pylon-sw.js") return "network";
|
|
104
110
|
if (request.mode === "navigate") return "navigate";
|
|
111
|
+
if (path === "/_pylon/build/manifest.json") return "build-manifest";
|
|
105
112
|
if (path.startsWith("/_pylon/build/")) return "build";
|
|
106
113
|
}
|
|
107
114
|
if (!sameOrigin && config.buildPrefix && url.href.startsWith(config.buildPrefix)) {
|
|
@@ -277,6 +284,23 @@ export function pylonServiceWorker(
|
|
|
277
284
|
return res;
|
|
278
285
|
}
|
|
279
286
|
|
|
287
|
+
// The build manifest has one URL for every build: ask the server first, so
|
|
288
|
+
// a page gets the manifest of the build the server runs. The copy kept at
|
|
289
|
+
// install answers only when the network fails or is slow.
|
|
290
|
+
async function networkFirst(request: any): Promise<any> {
|
|
291
|
+
const cache = await sw.caches.open(ASSETS);
|
|
292
|
+
const network = sw.fetch(request);
|
|
293
|
+
try {
|
|
294
|
+
const res = await withTimeout<any>(network, config.navigationTimeoutMs);
|
|
295
|
+
if (res && res.ok) await cache.put(request, res.clone());
|
|
296
|
+
return res;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
const hit = await cache.match(request);
|
|
299
|
+
if (hit) return hit;
|
|
300
|
+
return network;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
280
304
|
sw.addEventListener("install", (event: any) => {
|
|
281
305
|
event.waitUntil(
|
|
282
306
|
(async () => {
|
|
@@ -312,6 +336,7 @@ export function pylonServiceWorker(
|
|
|
312
336
|
const request = event.request;
|
|
313
337
|
const kind = classify(request, config, sw.location.origin);
|
|
314
338
|
if (kind === "navigate") event.respondWith(handleNavigation(request));
|
|
339
|
+
else if (kind === "build-manifest") event.respondWith(networkFirst(request));
|
|
315
340
|
else if (kind === "build" || kind === "precached") event.respondWith(cacheFirst(request));
|
|
316
341
|
});
|
|
317
342
|
|
|
@@ -362,6 +362,9 @@ export const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client
|
|
|
362
362
|
import { createElement, Fragment, Suspense } from "react";
|
|
363
363
|
import { hydrateRoot } from "react-dom/client";
|
|
364
364
|
import { configureClient, registerOfflineWorker } from "@pylonsync/react";
|
|
365
|
+
// A namespace import for what an older @pylonsync/react may not export, so
|
|
366
|
+
// an app on it still builds (the missing export is undefined).
|
|
367
|
+
import * as pylonReact from "@pylonsync/react";
|
|
365
368
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
366
369
|
import { LOADING_MODULES } from "./loading-registry";
|
|
367
370
|
import { createNavPayloadCache } from "./nav-cache";
|
|
@@ -380,6 +383,9 @@ let currentComponent = null;
|
|
|
380
383
|
let pendingNav = null;
|
|
381
384
|
let manifestPromise = null;
|
|
382
385
|
const prefetchedChunks = new Set();
|
|
386
|
+
// The build this page runs (the service worker version from
|
|
387
|
+
// __PYLON_DATA__), or null in an app without \`buildManifest({ offline })\`.
|
|
388
|
+
let pageBuild = null;
|
|
383
389
|
|
|
384
390
|
function buildTree(Page, Layouts, props, loadingFor) {
|
|
385
391
|
let tree = createElement(Page, props);
|
|
@@ -610,17 +616,64 @@ function readPylonData() {
|
|
|
610
616
|
}
|
|
611
617
|
}
|
|
612
618
|
|
|
619
|
+
// The build manifest of this page's build. A manifest of another build is
|
|
620
|
+
// not used: its route files cannot run next to this page's (two copies of
|
|
621
|
+
// React, a layout from each build). That happens when an older service
|
|
622
|
+
// worker answers from its cache, or when the server already runs a newer
|
|
623
|
+
// build. Navigation then moves the page to the server's build
|
|
624
|
+
// (moveToLatestBuild). A failed fetch is not kept, so the next call asks
|
|
625
|
+
// again.
|
|
613
626
|
async function loadManifest() {
|
|
614
627
|
if (!manifestPromise) {
|
|
615
|
-
|
|
628
|
+
const pending = fetch("/_pylon/build/manifest.json", {
|
|
616
629
|
credentials: "same-origin",
|
|
617
630
|
})
|
|
618
631
|
.then((r) => (r.ok ? r.json() : null))
|
|
619
|
-
.catch(() => null)
|
|
632
|
+
.catch(() => null)
|
|
633
|
+
.then((manifest) => {
|
|
634
|
+
if (!manifest && manifestPromise === pending) manifestPromise = null;
|
|
635
|
+
const build = manifest && manifest.offline && manifest.offline.version;
|
|
636
|
+
if (build && pageBuild && build !== pageBuild) {
|
|
637
|
+
console.warn(
|
|
638
|
+
"[pylon ssr] the build manifest is for build " + build +
|
|
639
|
+
", and this page runs build " + pageBuild + ". Not using it.",
|
|
640
|
+
);
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
return manifest;
|
|
644
|
+
});
|
|
645
|
+
manifestPromise = pending;
|
|
620
646
|
}
|
|
621
647
|
return manifestPromise;
|
|
622
648
|
}
|
|
623
649
|
|
|
650
|
+
// Move this page to the server's build when it runs another one (see
|
|
651
|
+
// moveToLatestBuild in @pylonsync/react): the outbox is sent first, the
|
|
652
|
+
// waiting service worker takes over, and \`url\` loads once. Resolves with
|
|
653
|
+
// what happened: "moving", "current", "offline", "pending-writes",
|
|
654
|
+
// "blocked", or "inert" (no offline build, or an older @pylonsync/react).
|
|
655
|
+
function moveToLatestBuild(options) {
|
|
656
|
+
const move = pylonReact.moveToLatestBuild;
|
|
657
|
+
if (typeof move !== "function") return Promise.resolve("inert");
|
|
658
|
+
return move(options).catch((e) => {
|
|
659
|
+
console.warn("[pylon ssr] could not move to the new build:", e);
|
|
660
|
+
return "inert";
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A route entry failed to load, or the server answered with a page of
|
|
665
|
+
// another build. True when navigate() is done: the page is loading the
|
|
666
|
+
// server's build, or the device is offline and the page keeps its screen.
|
|
667
|
+
// False when the caller falls back to a full page load (the server runs
|
|
668
|
+
// this build, or the page already moved and is still on it).
|
|
669
|
+
async function movedForFailedRoute(href) {
|
|
670
|
+
const result = await moveToLatestBuild({ url: href, recheck: true });
|
|
671
|
+
if (result === "offline") {
|
|
672
|
+
console.warn("[pylon ssr] offline: " + href + " cannot open now.");
|
|
673
|
+
}
|
|
674
|
+
return result === "moving" || result === "offline";
|
|
675
|
+
}
|
|
676
|
+
|
|
624
677
|
function preloadChunks(routeInfo) {
|
|
625
678
|
if (!routeInfo) return;
|
|
626
679
|
const prefix = routeInfo.public_prefix || "/_pylon/build/";
|
|
@@ -782,6 +835,7 @@ export function hydrate(component, Page, Layouts, metadata) {
|
|
|
782
835
|
);
|
|
783
836
|
return;
|
|
784
837
|
}
|
|
838
|
+
pageBuild = (data.offline && data.offline.version) || null;
|
|
785
839
|
setNavParams(data);
|
|
786
840
|
currentPageProps = withClientProps(data);
|
|
787
841
|
currentComponent = data.component;
|
|
@@ -936,6 +990,15 @@ async function navigate(href, opts) {
|
|
|
936
990
|
// timer that fires late can never paint a skeleton over real content.
|
|
937
991
|
const navState = { settled: false };
|
|
938
992
|
|
|
993
|
+
// ---- New build -----------------------------------------------------------
|
|
994
|
+
// The last check found a newer build on the server: open the link in it
|
|
995
|
+
// instead of rendering it with this page's build, whose files the deploy
|
|
996
|
+
// removed. Not while writes wait to be sent; a failure below moves then.
|
|
997
|
+
if (activeRoot) {
|
|
998
|
+
const early = await moveToLatestBuild({ url: href, skipIfPendingWrites: true });
|
|
999
|
+
if (early === "moving" || myEpoch !== navEpoch) return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
939
1002
|
// ---- Instant navigation --------------------------------------------------
|
|
940
1003
|
// A route whose page exports \`navigation = "client"\`, or whose URL is an
|
|
941
1004
|
// offline route, renders from the client with no page fetch: its data comes
|
|
@@ -950,8 +1013,11 @@ async function navigate(href, opts) {
|
|
|
950
1013
|
try {
|
|
951
1014
|
route = await loadRouteEntry(matched.component);
|
|
952
1015
|
} catch (e) {
|
|
953
|
-
//
|
|
954
|
-
//
|
|
1016
|
+
// After a deploy: move to the new build. Offline: keep the screen.
|
|
1017
|
+
// Otherwise fall through to the fetch below, which reloads the page
|
|
1018
|
+
// when the entry cannot load.
|
|
1019
|
+
if (myEpoch !== navEpoch) return;
|
|
1020
|
+
if (await movedForFailedRoute(href)) return;
|
|
955
1021
|
console.warn("[pylon ssr] client navigation fell back to a page fetch:", e);
|
|
956
1022
|
}
|
|
957
1023
|
if (myEpoch !== navEpoch) return;
|
|
@@ -1090,15 +1156,29 @@ async function navigate(href, opts) {
|
|
|
1090
1156
|
fullLoad();
|
|
1091
1157
|
return;
|
|
1092
1158
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1159
|
+
// The server runs another build: this page's route files cannot render
|
|
1160
|
+
// its pages.
|
|
1161
|
+
const otherBuild =
|
|
1162
|
+
pageBuild && data.offline && data.offline.version && data.offline.version !== pageBuild;
|
|
1163
|
+
let route = null;
|
|
1164
|
+
if (!otherBuild) {
|
|
1165
|
+
try {
|
|
1166
|
+
route = await loadRouteEntry(data.component);
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
console.warn("[pylon ssr] nav fallback (entry load failed):", e);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (myEpoch !== navEpoch) return;
|
|
1172
|
+
if (!route) {
|
|
1173
|
+
if (await movedForFailedRoute(href)) {
|
|
1174
|
+
navState.settled = true;
|
|
1175
|
+
if (placeholderTimer) clearTimeout(placeholderTimer);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (myEpoch !== navEpoch) return;
|
|
1098
1179
|
fullLoad();
|
|
1099
1180
|
return;
|
|
1100
1181
|
}
|
|
1101
|
-
if (myEpoch !== navEpoch) return;
|
|
1102
1182
|
navState.settled = true;
|
|
1103
1183
|
if (placeholderTimer) clearTimeout(placeholderTimer);
|
|
1104
1184
|
document.title = doc.title || document.title;
|