@uniweb/runtime 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.6.6",
40
- "@uniweb/theming": "~1.3.0"
39
+ "@uniweb/theming": "~1.3.1",
40
+ "@uniweb/core": "~1.6.7"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.17.2"
47
+ "@uniweb/build": "1.18.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -35,7 +35,7 @@ import { useEffect } from 'react'
35
35
  import Blocks from './Blocks.jsx'
36
36
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
37
37
  import { layoutStickiness, zoneProps } from '../sticky.js'
38
- import { headerOverlay, overlayBoxProps } from '../under-header.js'
38
+ import { headerOverlay, overlayBoxProps, headerAreaProps } from '../under-header.js'
39
39
  import { warnUnmovableSticky } from '../sticky-check.js'
40
40
  import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
41
41
  // The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
@@ -102,8 +102,12 @@ export default function Layout({ page, website }) {
102
102
  const overlay = headerOverlay(page)
103
103
 
104
104
  const wrap = (region, children) => {
105
- let props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
106
- if (overlay && RemoteLayout && region === 'header') props = overlayBoxProps(props)
105
+ const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
106
+ if (RemoteLayout && region === 'header') {
107
+ // The same boxes on every page, the name on one that keeps the header's height.
108
+ const { outer, inner } = headerAreaProps(props, overlay)
109
+ return <div {...outer}>{inner ? <div {...inner}>{children}</div> : children}</div>
110
+ }
107
111
  return props ? <div {...props}>{children}</div> : children
108
112
  }
109
113
 
@@ -126,13 +126,12 @@ export default function PageRenderer() {
126
126
 
127
127
  // ⭐ WHAT THE URL NAMES — `Website#resolveRoute`, the one rule every lane calls
128
128
  // (`@uniweb/core/resolve-route`): a page, a redirect, a page served from elsewhere, or
129
- // nothing. Resolved synchronously (not in an effect) so child components like Header see the
130
- // correct activePage during the same render cycle.
129
+ // nothing. Resolved synchronously (not in an effect), and the page drawn made active below,
130
+ // so child components like Header see it during the same render cycle.
131
131
  const navigate = useNavigate()
132
132
 
133
133
  const resolution = website?.resolveRoute?.(location.pathname) ?? null
134
134
  let page = resolution && resolution.kind !== 'notFound' ? resolution.page : null
135
- if (page && website) website.setActivePage(location.pathname)
136
135
 
137
136
  // ─── Content loading (split page content) ───
138
137
  // On first load, the current page's sections are pre-embedded → contentReady = true.
@@ -182,6 +181,13 @@ export default function PageRenderer() {
182
181
  page = website?.getNotFoundPage?.() || null
183
182
  }
184
183
 
184
+ // ⭐ THE PAGE DRAWN IS THE ACTIVE ONE, by the Page itself, as the prerender makes it
185
+ // (`renderPage`): a header reading its next block, the language switcher, page state and a
186
+ // contents rail all read the page on screen, the not-found page included. ⛔ Until 2026-11-06 it
187
+ // was made active by the URL, so the not-found page drew with another page active, and a
188
+ // parametric page whose records were not held yet was looked up anew, another Page than this one.
189
+ if (page && website) website.setActivePage(page)
190
+
185
191
  // Head metadata — compute for all cases (redirect pages get a fallback)
186
192
  const headMeta = page?.getHeadMeta?.() || {
187
193
  title: isNotFound ? 'Page Not Found' : (page?.title || 'Website'),
@@ -38,7 +38,6 @@ import { resolveRequestedPage } from './resolve-page.js'
38
38
  import { hydrateDataStore } from './wire-foundation.js'
39
39
  import { loadPageData } from './load-page-data.js'
40
40
  import { requireTransport } from './require-transport.js'
41
- import { restoreStartPage } from './start-page.js'
42
41
  import { whileReadingIcons } from './page-icons.js'
43
42
 
44
43
  /**
@@ -152,15 +151,11 @@ export function createPageRenderer({ website, shell }) {
152
151
  // host that answered by the outcome sent 200 for a URL naming no record.
153
152
  const missing = !named || page?.notFound === true
154
153
 
155
- // ⭐ EVERY RENDER STARTS FROM THE ACTIVE PAGE THE WEBSITE STARTED WITH (`start-page.js`),
156
- // then makes active the page the route names, as the browser's boot does (`index.jsx`).
157
- // `renderPage` then makes the page it renders active by its route, and the not-found page
158
- // has none: it renders with the page a route served from elsewhere names, as in the browser,
159
- // or with the start page. ⛔ Until 2026-10-26 it rendered with whatever page this Website
160
- // rendered last, so on a Website reused across requests its header could show the previous
161
- // request's page.
162
- restoreStartPage(website)
163
- if (typeof target === 'string') website.setActivePage(target)
154
+ // ⭐ `renderPage` makes the page it renders active, by the Page, the not-found page included,
155
+ // so no render sees the page an earlier request rendered. ⛔ Until 2026-10-26 the not-found
156
+ // page rendered with whatever page this Website rendered last, so on a Website reused across
157
+ // requests its header could show the previous request's page; until 2026-11-06 it rendered with
158
+ // the page the Website started with (or a page served from elsewhere that the route named).
164
159
  // ⭐ The page carries the icons it drew, so the browser's first render draws them too.
165
160
  const icons = new Map()
166
161
  let result
@@ -34,11 +34,10 @@ import {
34
34
  } from './wire-foundation.js'
35
35
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
36
36
  import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps } from './sticky.js'
37
- import { headerOverlay, overlayBoxProps, openingUnderHeader, underHeaderProps } from './under-header.js'
37
+ import { headerOverlay, overlayBoxProps, openingUnderHeader, underHeaderProps, headerAreaProps } from './under-header.js'
38
38
  import { DefaultLayout } from './default-layout.js'
39
39
  import { renderAppearanceBootScript } from './appearance.js'
40
40
  import { documentTitle } from './document-title.js'
41
- import { recordStartPage } from './start-page.js'
42
41
  import { escapeHtml } from '@uniweb/core/page-shell'
43
42
  import { jsonForScript } from '@uniweb/core/data-script'
44
43
 
@@ -275,8 +274,12 @@ export function renderLayout(page, website) {
275
274
  // Mirror Layout.jsx: the header lies over a page's opening section under it (`under-header.js`).
276
275
  const overlay = headerOverlay(page)
277
276
  const wrapArea = (name, element) => {
278
- let props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
279
- if (overlay && RemoteLayout && name === 'header') props = overlayBoxProps(props)
277
+ const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
278
+ if (RemoteLayout && name === 'header') {
279
+ // Mirror Layout.jsx: the same boxes on every page, the name on one that keeps the header's height.
280
+ const { outer, inner } = headerAreaProps(props, overlay)
281
+ return React.createElement('div', outer, inner ? React.createElement('div', inner, element) : element)
282
+ }
280
283
  return props ? React.createElement('div', props, element) : element
281
284
  }
282
285
 
@@ -376,8 +379,6 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
376
379
  // Uniweb constructor wires foundation, capabilities, layoutMeta, handlers,
377
380
  // and extensions at construction time — see `@uniweb/core`'s src/uniweb.js.
378
381
  const uniweb = createUniweb(content, foundation, extensions)
379
- // Before anything renders: the active page every render starts from (`start-page.js`).
380
- recordStartPage(uniweb.activeWebsite)
381
382
 
382
383
  // Set base path from site config for subdirectory deployments
383
384
  if (content.config?.base && uniweb.activeWebsite?.setBasePath) {
@@ -415,12 +416,8 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
415
416
  ensureThemeCss(uniweb, foundation)
416
417
 
417
418
  // Register SSR-safe routing so useRouting()/useActiveRoute() work during prerender.
418
- // renderPage() calls website.setActivePage() before rendering each page,
419
- // so activePage.route reflects the page being rendered — except the site's
420
- // not-found page, which no route names. Through `createPageRenderer` it renders
421
- // with the page a route served from elsewhere names, else the page the Website
422
- // starts with (`start-page.js`); through `generate404Html`, with the page
423
- // rendered last.
419
+ // renderPage() makes the page it renders active before rendering it, so
420
+ // activePage.route is the page being rendered, the not-found page included.
424
421
  const website = uniweb.activeWebsite
425
422
  uniweb.routingComponents = {
426
423
  useLocation: () => {
@@ -548,11 +545,13 @@ export function classifyRenderError(err) {
548
545
  * @returns {{ renderedContent: string, sectionOverrideCSS: string } | { error: { type: string, message: string } }}
549
546
  */
550
547
  export function renderPage(page, website) {
551
- // ⛔ The site's not-found page is never made active by its route. No route names it, and on a
552
- // site whose parametric page matches `/404` (a root `/:slug`) that route names THAT page, which
553
- // then rendered as the active one. It renders with the active page it finds: the Website's
554
- // first, through `createPageRenderer` (`start-page.js`), as in the browser.
555
- if (page !== website.getNotFoundPage()) website.setActivePage(page.route)
548
+ // ⭐ The page it renders is the active one, by the Page itself, as in the browser
549
+ // (`PageRenderer.jsx`): whatever adapts to the page — a header reading its next block, the
550
+ // language switcher, page state, a contents rail — reads the page on screen. ⛔ Until 2026-11-06
551
+ // it was made active by its route. The site's not-found page has none (on a site whose
552
+ // parametric page matches `/404`, that route names THAT page), so it rendered with another page
553
+ // active, and a parametric page whose records were not held yet was made anew by the lookup.
554
+ website.setActivePage(page)
556
555
 
557
556
  // A page that claims content but yields no blocks has not been loaded — it is
558
557
  // not an empty page. `Page#bodyBlocks` returns [] when its sections are absent
@@ -25,6 +25,13 @@
25
25
  * Table parts are left out: a sticky table header cell sticks within its table, though its row is
26
26
  * no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
27
27
  * with no room — stacked at one column, or the tallest in its row — it does not stick, by design.
28
+ * And so is an element whose box is sticky itself: the box moves, and the element with it — the
29
+ * runtime's area wrapper inside a layout's own element that `page.isSticky(area)` made sticky, as
30
+ * the docs say to. ⛔ Until 2026-11-06 that layout was told to do what it had done.
31
+ *
32
+ * ⭐ A header over the page's opening section is checked too (`under-header.js`): when what it
33
+ * holds takes no room, a bar made `fixed` or `absolute`, the runtime measures 0px, so the section
34
+ * is not cleared and a link to a section lands under the bar — and nothing else says why.
28
35
  *
29
36
  * And an element that sits in a box that CLIPS — `overflow: hidden`, or `overflow-x: hidden`
30
37
  * alone, which makes the box scroll vertically too — sticks to that box in place of the page,
@@ -33,6 +40,8 @@
33
40
  * (a layout's own scrolling `<main>`) is the element's scroller, and is left alone.
34
41
  */
35
42
 
43
+ import { measureHeaderOverlay } from './sticky-top.js'
44
+
36
45
  const warned = new WeakSet()
37
46
 
38
47
  /** Every section wrapper the browser's renderer drew, noted in dev (`BlockRenderer.jsx`). */
@@ -125,10 +134,37 @@ export function unmovableAdvice(el) {
125
134
  )
126
135
  }
127
136
 
137
+ /**
138
+ * The box of a header over the opening section, when what it holds takes no room though some of
139
+ * it is drawn: a bar out of the flow — or null.
140
+ *
141
+ * @param {Document} doc
142
+ * @returns {Element|null}
143
+ */
144
+ export function headerOutOfFlow(doc) {
145
+ const box = doc.querySelector?.('[data-uw-header="over"]')
146
+ if (!box || measureHeaderOverlay(doc) !== 0) return null
147
+ const drawn = [...box.querySelectorAll('*')].some((el) => el.getBoundingClientRect().height > 0)
148
+ return drawn ? box : null
149
+ }
150
+
128
151
  /** Warn, once per element, about each sticky element on the page that cannot move. */
129
152
  function checkPage(doc) {
153
+ const header = headerOutOfFlow(doc)
154
+ if (header && !warned.has(header)) {
155
+ warned.add(header)
156
+ console.warn(
157
+ '[uniweb] The header lies over the page\'s opening section, but what it holds takes no room: a bar ' +
158
+ 'made `fixed` or `absolute` leaves the runtime nothing to measure, so the section is cleared by ' +
159
+ '0px and a link to a section lands under the bar. Keep the bar in the flow; the runtime lays ' +
160
+ 'the header over the section and keeps it stuck.',
161
+ header,
162
+ )
163
+ }
130
164
  for (const el of doc.body.querySelectorAll('*')) {
131
165
  if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
166
+ const parent = boxParent(el)
167
+ if (parent && getComputedStyle(parent).position === 'sticky') continue
132
168
  const clip = clippingAncestor(el)
133
169
  if (clip) {
134
170
  warned.add(el)
package/src/sticky-top.js CHANGED
@@ -65,14 +65,19 @@ function drawnHeight(box) {
65
65
  }
66
66
 
67
67
  /**
68
- * How tall the header lying over the page's opening section is, in px — null on a page with none.
68
+ * How tall the header lying over the page's opening section is, in px — 0 when the layout drew no
69
+ * header on such a page, null on a page with no section under the header.
69
70
  *
70
71
  * @param {Document} doc
71
72
  * @returns {number|null}
72
73
  */
73
74
  export function measureHeaderOverlay(doc) {
74
75
  const box = doc.querySelector('[data-uw-header="over"]')
75
- return box ? Math.round(drawnHeight(box) * 100) / 100 : null
76
+ if (box) return Math.round(drawnHeight(box) * 100) / 100
77
+ // A page whose opening section lies under the header, in a layout that drew no header on it:
78
+ // there is nothing to clear, so the section is padded by nothing once the browser has looked.
79
+ // ⛔ Until 2026-11-06 it kept the declared height, padding the section for a header not there.
80
+ return doc.querySelector('[data-uw-under-header]') ? 0 : null
76
81
  }
77
82
 
78
83
  /**
@@ -17,7 +17,9 @@
17
17
  * The opening section keeps its content clear of the header by `--uw-header-height`, which the
18
18
  * runtime gives its wrapper: the header type's `height` on the first paint, which no prerender can
19
19
  * measure, then what the browser measures. The header reads `getNextBlockInfo().underHeader` to
20
- * paint itself see-through, and its box says `data-uw-header="over"` for a stylesheet.
20
+ * paint itself see-through, and its box says `data-uw-header="over"` for a stylesheet. Its type
21
+ * says `background: 'self'` and paints its own bar: otherwise the theme paints the header's section
22
+ * with its surface, a solid band over the opening section (measured 2026-11-06).
21
23
  */
22
24
 
23
25
  /** The header's height, on the opening section's wrapper: what its content is padded by. */
@@ -55,6 +57,36 @@ export function overlayBoxProps(props) {
55
57
  return { ...(props || {}), style: { ...(props?.style || {}), height: 0, minHeight: 0 }, 'data-uw-header': 'over' }
56
58
  }
57
59
 
60
+ /**
61
+ * The boxes a foundation layout's header area is drawn in, the same in both renderers.
62
+ *
63
+ * ⭐ THE SAME ELEMENTS ON EVERY PAGE. Between a page whose opening section lies under the header and
64
+ * one with none, only styles change, so React keeps the header mounted, with its state: a search
65
+ * box's words, a menu, an animation that ran once. So the area always has its wrapper — its layer
66
+ * and stickiness, collapsed on a page under the header, and `display: contents`, no box at all,
67
+ * where it has nothing to carry (a layout that ranks its own stacking and a header that neither
68
+ * sticks nor is named). ⛔ For part of 2026-11-06 a wrapper appeared only on a page under the
69
+ * header there, and React rebuilt the header on each move between the two kinds of page.
70
+ *
71
+ * ⭐ THE NAME A LAYOUT ASKS FOR GOES ON AN ELEMENT INSIDE THE WRAPPER, which keeps the header's
72
+ * height on every page. A named box is captured at its own size, and one with no height is captured
73
+ * as nothing, so on the collapsed wrapper the header was gone for the length of a page switch; the
74
+ * element inside is the header's extent everywhere, and two pages' headers pair as the layout means.
75
+ * ⛔ For part of 2026-11-06 a collapsed wrapper dropped the name instead, which quietly ignored what
76
+ * the layout asked for on exactly those pages.
77
+ *
78
+ * @param {Object|null} wrapper - the area wrapper's props (`areaWrapperProps`), its name included
79
+ * @param {{ height: string }|null} overlay - `headerOverlay(page)`
80
+ * @returns {{ outer: Object, inner: Object|null }} the wrapper's props, and the named element's
81
+ */
82
+ export function headerAreaProps(wrapper, overlay) {
83
+ const { viewTransitionName, ...style } = wrapper?.style || {}
84
+ const inner = viewTransitionName ? { style: { viewTransitionName } } : null
85
+ const base = { ...(wrapper || {}), style }
86
+ if (overlay) return { outer: overlayBoxProps(base), inner }
87
+ return { outer: Object.keys(style).length ? base : { ...base, style: { display: 'contents' } }, inner }
88
+ }
89
+
58
90
  /**
59
91
  * What the opening section's wrapper adds when the header lies over it: the header's height, the
60
92
  * declared one until the browser has measured it.
package/src/start-page.js DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * The page a Website starts with as active — recorded when the SSR init builds the graph, and put
3
- * back before each render (`createPageRenderer`).
4
- *
5
- * ⭐ It is what the browser has on a URL that names no page. Core makes the site's index page (or
6
- * its first page) active when it builds the Website, and neither the boot nor `PageRenderer` makes
7
- * the not-found page active, since no route names it.
8
- *
9
- * ⛔ `renderPage` makes the page it renders active by its route, so for the not-found page it
10
- * changes nothing, and that page rendered with whatever page the Website rendered last: on a
11
- * Website reused across requests, the previous request's. Anything that adapts to the active page
12
- * — a header, a language switcher, a docs sidebar — showed that page.
13
- *
14
- * A WeakMap rather than a slot on core's sealed Website: it is SSR state, and core is in every site.
15
- */
16
- const startPages = new WeakMap()
17
-
18
- /** Remember `website`'s active page as the one it starts with. Call it before anything renders. */
19
- export function recordStartPage(website) {
20
- if (website && !startPages.has(website)) startPages.set(website, website.activePage)
21
- }
22
-
23
- /** Make `website`'s start page active again. A Website the SSR init did not build is left alone. */
24
- export function restoreStartPage(website) {
25
- if (startPages.has(website)) website.activePage = startPages.get(website)
26
- }