@uniweb/runtime 1.3.14 → 1.4.0
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/ssr.js +3 -8
- package/dist/ssr.js.map +1 -1
- package/package.json +2 -2
- package/src/area-wrappers.js +51 -58
- package/src/components/Layout.jsx +4 -6
- package/src/navigation.js +1 -25
- package/src/ssr-renderer.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
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.14.
|
|
47
|
+
"@uniweb/build": "1.14.4"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/area-wrappers.js
CHANGED
|
@@ -1,36 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the runtime puts on a layout-area wrapper
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
2
|
+
* What the runtime puts on a layout-area wrapper: its stacking layer, and the
|
|
3
|
+
* `view-transition-name` its layout asks for, if any.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **An area takes no name unless its layout asks.** A navigation crossfades the
|
|
6
|
+
* page, and chrome that stays in place stays steady, since the old page and the new
|
|
7
|
+
* one agree there (measured 2026-11-02 in Chromium and WebKit). A name takes a box out
|
|
8
|
+
* of that crossfade and animates it from where it was on screen to where it lands.
|
|
9
|
+
* That is worth having for a morph a layout means, and does harm to a box nobody
|
|
10
|
+
* thought about. Until 2026-11-02 the runtime named every area, with these results:
|
|
11
|
+
*
|
|
12
|
+
* - a bar `fixed` in a box that scrolls with the page moved by the whole scroll on a
|
|
13
|
+
* switch from a scrolled page: the old bar dropped through the page as it faded,
|
|
14
|
+
* and the new one came down from above;
|
|
15
|
+
* - a visible footer moved the same way, off the bottom of the screen;
|
|
16
|
+
* - a box with no height is captured as nothing, so a bar overlaying the page was
|
|
17
|
+
* gone for the length of every switch;
|
|
18
|
+
* - in WebKit a bar kept in place, `fixed` or stuck, faded out over a switch and came
|
|
19
|
+
* back at the end;
|
|
20
|
+
* - a name makes a Backdrop Root, so a bar's `backdrop-blur` saw nothing past its
|
|
21
|
+
* wrapper, at rest too.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ **A name is also a capture.** A named element is captured as one image. WebKit
|
|
24
|
+
* captures it whole, off-screen part included, at device resolution, painting every
|
|
25
|
+
* filter in it on the main thread; the page itself is captured at the size of the
|
|
26
|
+
* viewport. Measured 2026-10-30 on a live site, in WebKit on a 3× phone viewport with a
|
|
27
|
+
* desktop CPU: a named body was a 1179 × 22710 capture, 107 MB against 9 MB, and a tap
|
|
28
|
+
* stalled the main thread for up to 842 ms, against 271 ms with no name. Chromium did
|
|
29
|
+
* not stall at all. On an iPhone, that site froze for about 30 s on a tap.
|
|
30
|
+
*
|
|
31
|
+
* The wrapper's standing job is the order areas paint in. A layer makes its wrapper
|
|
32
|
+
* **a stacking context**, as a section's own `z-index` and background routinely make
|
|
33
|
+
* theirs, and with no order they paint by position in the DOM, which put the body over
|
|
34
|
+
* the header on any layout that renders the header first.
|
|
35
|
+
*
|
|
36
|
+
* That is not theoretical. It is the same mechanism `@uniweb/kit`'s `Overlay` exists
|
|
37
|
+
* for (a modal opened from the header, trapped inside the header's wrapper), and it
|
|
38
|
+
* made a real docs page's fixed header unclickable while the identical header on the
|
|
39
|
+
* marketing layout was fine — because that layout's markup happened to wrap its header
|
|
40
|
+
* area in `relative z-40`. A framework that creates stacking contexts owes its users an
|
|
41
|
+
* order; leaving it to DOM order means "does my header work" is answered by an accident
|
|
42
|
+
* of someone's JSX.
|
|
34
43
|
*
|
|
35
44
|
* So this module resolves BOTH halves of the wrapper — the transition name and
|
|
36
45
|
* the stacking layer — and hands back the finished style. It is pure (no
|
|
@@ -41,38 +50,22 @@
|
|
|
41
50
|
|
|
42
51
|
import { stickyStyle } from './sticky.js'
|
|
43
52
|
|
|
44
|
-
// Namespace so generated names can't collide with `view-transition-name`s a
|
|
45
|
-
// foundation sets inside its own component CSS. The prefix also guarantees a
|
|
46
|
-
// valid CSS <custom-ident> (starts with a letter).
|
|
47
|
-
const NS = 'uw-'
|
|
48
|
-
|
|
49
|
-
const toIdent = (name) => NS + String(name).replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
50
|
-
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* Default: every rendered area gets a stable, namespaced name (`uw-<area>`), so
|
|
55
|
-
* same-named areas across layouts share a name and morph between layouts
|
|
56
|
-
* automatically. The body gets none and crossfades as the page (the header above).
|
|
54
|
+
* A layout's view-transition names: what its `meta.js` `transitions` asks for.
|
|
57
55
|
*
|
|
58
|
-
*
|
|
59
|
-
* - an object
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* - `false` opts the
|
|
56
|
+
* - nothing: no area takes a name, and the page crossfades as one (the header above);
|
|
57
|
+
* - an object names regions (`{ left: 'sidebar' }`), and two layouts that give a
|
|
58
|
+
* region the same name morph it between them. A body may be named (`{ body: 'main' }`),
|
|
59
|
+
* and WebKit captures it whole;
|
|
60
|
+
* - `false` opts the layout out, which leaves its body with no wrapper
|
|
61
|
+
* (`areaWrapperStyle`).
|
|
63
62
|
*
|
|
64
|
-
* @param {string[]} areaNames - Names of the areas rendered for this page (excludes `body`).
|
|
65
63
|
* @param {Object|false|null|undefined} explicit - `layoutMeta.transitions`.
|
|
66
64
|
* @returns {Object|null} region → view-transition-name; `null` when opted out.
|
|
67
|
-
* A region whose value is
|
|
65
|
+
* A region whose value is absent or empty gets no name.
|
|
68
66
|
*/
|
|
69
|
-
export function resolveLayoutTransitions(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const transitions = {}
|
|
73
|
-
for (const name of areaNames) transitions[name] = toIdent(name)
|
|
74
|
-
|
|
75
|
-
return explicit ? { ...transitions, ...explicit } : transitions
|
|
67
|
+
export function resolveLayoutTransitions(explicit) {
|
|
68
|
+
return explicit === false ? null : explicit || {}
|
|
76
69
|
}
|
|
77
70
|
|
|
78
71
|
/**
|
|
@@ -83,14 +83,12 @@ export default function Layout({ page, website }) {
|
|
|
83
83
|
const allBlockGroups = [bodyBlocks, ...Object.values(areas)]
|
|
84
84
|
initializeAllBlocks(...allBlockGroups)
|
|
85
85
|
|
|
86
|
-
// Pre-render each area as React elements.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// default to one per area and none for the body (see resolveLayoutTransitions);
|
|
90
|
-
// the layout's `transitions` meta overrides or opts out.
|
|
86
|
+
// Pre-render each area as React elements, each in a thin div with its stacking layer.
|
|
87
|
+
// A region takes a view-transition-name only where the layout's `transitions` meta asks
|
|
88
|
+
// for one (see resolveLayoutTransitions); everything else crossfades as the page.
|
|
91
89
|
const areaNames = Object.keys(areas)
|
|
92
90
|
const transitions = website.viewTransitions
|
|
93
|
-
? resolveLayoutTransitions(
|
|
91
|
+
? resolveLayoutTransitions(layoutMeta?.transitions)
|
|
94
92
|
: null
|
|
95
93
|
// An area stays in view when any of its sections is sticky (`../sticky.js`): in a foundation's
|
|
96
94
|
// layout its wrapper sticks; in the built-in one, the zone around the wrapper does. Either way
|
package/src/navigation.js
CHANGED
|
@@ -74,29 +74,6 @@ function pageAt(to) {
|
|
|
74
74
|
return website.getPage(route.split(/[?#]/)[0]) || null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* Folds an area with no height back into the page's crossfade, as a `null` in a layout's `transitions`
|
|
79
|
-
* would.
|
|
80
|
-
*
|
|
81
|
-
* ⛔ A named element is captured at its own size, so a box with no height is captured as nothing,
|
|
82
|
-
* whatever overflows it. A bar that overlays the page is that box: `fixed`, or `absolute` in a sticky
|
|
83
|
-
* box it doesn't fill. Its name took it out of the page's capture and its own capture drew nothing,
|
|
84
|
-
* so the bar was gone for the length of every transition and came back at the end. Measured
|
|
85
|
-
* 2026-11-02 in Chromium and WebKit.
|
|
86
|
-
*
|
|
87
|
-
* An area with nothing in it keeps its name, since a sidebar that fills on the next page should still
|
|
88
|
-
* move on its own. A folded name stays folded: React wrote it and has no reason to write it again,
|
|
89
|
-
* except on a new element, so this runs again once a page has committed, for a layout change.
|
|
90
|
-
*/
|
|
91
|
-
function foldEmptyAreas() {
|
|
92
|
-
for (const area of document.querySelectorAll('[style*="view-transition-name"]')) {
|
|
93
|
-
const box = area.getClientRects()[0]
|
|
94
|
-
if (box && !box.height && [...area.querySelectorAll('*')].some((el) => el.getBoundingClientRect().height)) {
|
|
95
|
-
area.style.viewTransitionName = 'none'
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
77
|
/** Whether `page`'s content can be shown now: `PageRenderer`'s own test. */
|
|
101
78
|
function isReady(page) {
|
|
102
79
|
return !page || !page.hasContent() || page.isContentLoaded()
|
|
@@ -229,12 +206,11 @@ export function navigateInApp(navigate, to, options) {
|
|
|
229
206
|
navigate(to, options)
|
|
230
207
|
return
|
|
231
208
|
}
|
|
232
|
-
foldEmptyAreas()
|
|
233
209
|
const transition = document.startViewTransition(() => {
|
|
234
210
|
// Asked before navigating: a router that commits synchronously settles it from inside `navigate`.
|
|
235
211
|
const committed = nextPageCommit()
|
|
236
212
|
navigate(to, options)
|
|
237
|
-
return committed
|
|
213
|
+
return committed
|
|
238
214
|
})
|
|
239
215
|
// A transition the browser skips, for a duplicate `view-transition-name` or a newer
|
|
240
216
|
// transition, still runs its callback, so the page is swapped all the same. The browser
|
package/src/ssr-renderer.js
CHANGED
|
@@ -248,12 +248,12 @@ export function renderLayout(page, website) {
|
|
|
248
248
|
for (const block of blocks || []) block.initComponent()
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
// Mirror Layout.jsx: wrap the body and each area in a thin div,
|
|
252
|
-
// view-transition-name, so the prerendered HTML matches the page the
|
|
253
|
-
// in its place
|
|
251
|
+
// Mirror Layout.jsx: wrap the body and each area in a thin div, with its stacking layer and
|
|
252
|
+
// any view-transition-name the layout asks for, so the prerendered HTML matches the page the
|
|
253
|
+
// SPA renders in its place.
|
|
254
254
|
const areaNames = Object.keys(areas)
|
|
255
255
|
const transitions = website.viewTransitions
|
|
256
|
-
? resolveLayoutTransitions(
|
|
256
|
+
? resolveLayoutTransitions(layoutMeta?.transitions)
|
|
257
257
|
: null
|
|
258
258
|
|
|
259
259
|
// Mirror Layout.jsx: an area stays in view when any of its sections is sticky — in a
|