@uniweb/runtime 1.1.20 → 1.2.1
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 +138 -148
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/background-shared.js +16 -10
- package/src/background.js +182 -0
- package/src/collect-records.js +7 -1
- package/src/components/Background.jsx +19 -294
- package/src/components/BlockRenderer.jsx +10 -2
- package/src/components/Layout.jsx +3 -2
- package/src/components/PageRenderer.jsx +4 -2
- package/src/default-fetcher.js +4 -1
- package/src/hooks/useHeadMeta.js +42 -7
- package/src/load-page-data.js +6 -1
- package/src/page-renderer.js +21 -40
- package/src/prepare-props.js +31 -19
- package/src/require-transport.js +35 -0
- package/src/setup.js +2 -1
- package/src/ssr-renderer.js +86 -161
- package/src/sticky-check.js +106 -33
- package/src/sticky.js +5 -3
package/src/ssr-renderer.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* SSR Renderer
|
|
3
3
|
*
|
|
4
4
|
* Hook-free rendering pipeline for SSG (build) and server-side SSR.
|
|
5
|
-
* Mirrors BlockRenderer.jsx
|
|
6
|
-
*
|
|
5
|
+
* Mirrors BlockRenderer.jsx using React.createElement directly — no hooks,
|
|
6
|
+
* no JSX, no browser APIs. A section's background and the built-in layout are
|
|
7
|
+
* not mirrored: both renderers draw the one copy (`background.js`,
|
|
8
|
+
* `default-layout.js`).
|
|
7
9
|
*
|
|
8
10
|
* This is the single source of truth for how blocks render during prerender.
|
|
9
|
-
* When modifying BlockRenderer.jsx
|
|
11
|
+
* When modifying BlockRenderer.jsx, update `renderBlock` here to match.
|
|
10
12
|
*
|
|
11
13
|
* Exports three layers:
|
|
12
14
|
* 1. Rendering functions (renderBlock, renderBlocks, renderLayout, renderBackground)
|
|
@@ -18,7 +20,7 @@ import React from 'react'
|
|
|
18
20
|
import { renderToString } from 'react-dom/server'
|
|
19
21
|
import { createUniweb, resolveDefaultLocale } from '@uniweb/core'
|
|
20
22
|
import { sectionDomId } from '@uniweb/core/section-id'
|
|
21
|
-
import {
|
|
23
|
+
import { Background } from './background.js'
|
|
22
24
|
import { routePatternToRegex, isDynamicRoute } from '@uniweb/core/route-match'
|
|
23
25
|
import { DEFAULT_ICON_BASE, iconUrl } from '@uniweb/core/icon-corpus'
|
|
24
26
|
import { buildSectionOverrides, FONT_LINKS_MARKER } from '@uniweb/theming'
|
|
@@ -95,123 +97,17 @@ export function getWrapperProps(block) {
|
|
|
95
97
|
|
|
96
98
|
|
|
97
99
|
/**
|
|
98
|
-
* Render a background
|
|
99
|
-
*
|
|
100
|
-
*
|
|
100
|
+
* Render a section's background for SSR: the one implementation both renderers draw
|
|
101
|
+
* (`background.js`). A video background is drawn as its poster — what the browser draws too,
|
|
102
|
+
* until it has mounted and starts the video.
|
|
103
|
+
* ⛔ Until 2026-10-25 this was a copy of `Background.jsx`, and drew no video at all.
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} background - `block.background`, normalized by core
|
|
106
|
+
* @returns {React.ReactElement|null}
|
|
101
107
|
*/
|
|
102
108
|
export function renderBackground(background) {
|
|
103
109
|
if (!background?.mode) return null
|
|
104
|
-
|
|
105
|
-
const containerStyle = {
|
|
106
|
-
position: 'absolute',
|
|
107
|
-
inset: '0',
|
|
108
|
-
overflow: 'hidden',
|
|
109
|
-
zIndex: 0,
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const children = []
|
|
113
|
-
|
|
114
|
-
// Color background
|
|
115
|
-
if (background.mode === 'color' && background.color) {
|
|
116
|
-
children.push(
|
|
117
|
-
React.createElement('div', {
|
|
118
|
-
key: 'bg-color',
|
|
119
|
-
className: 'background-color',
|
|
120
|
-
style: { position: 'absolute', inset: '0', backgroundColor: background.color },
|
|
121
|
-
'aria-hidden': 'true',
|
|
122
|
-
})
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Gradient background (supports string or object with opacity)
|
|
127
|
-
if (background.mode === 'gradient' && background.gradient) {
|
|
128
|
-
const g = background.gradient
|
|
129
|
-
|
|
130
|
-
let bgValue
|
|
131
|
-
if (typeof g === 'string') {
|
|
132
|
-
bgValue = g
|
|
133
|
-
} else {
|
|
134
|
-
const {
|
|
135
|
-
start = 'transparent',
|
|
136
|
-
end = 'transparent',
|
|
137
|
-
angle = 0,
|
|
138
|
-
startPosition = 0,
|
|
139
|
-
endPosition = 100,
|
|
140
|
-
startOpacity = 1,
|
|
141
|
-
endOpacity = 1,
|
|
142
|
-
} = g
|
|
143
|
-
const startColor = startOpacity < 1 ? withOpacity(start, startOpacity) : start
|
|
144
|
-
const endColor = endOpacity < 1 ? withOpacity(end, endOpacity) : end
|
|
145
|
-
bgValue = `linear-gradient(${angle}deg, ${startColor} ${startPosition}%, ${endColor} ${endPosition}%)`
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
children.push(
|
|
149
|
-
React.createElement('div', {
|
|
150
|
-
key: 'bg-gradient',
|
|
151
|
-
className: 'background-gradient',
|
|
152
|
-
style: { position: 'absolute', inset: '0', background: bgValue },
|
|
153
|
-
'aria-hidden': 'true',
|
|
154
|
-
})
|
|
155
|
-
)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Image background
|
|
159
|
-
if (background.mode === 'image' && background.image?.src) {
|
|
160
|
-
const img = background.image
|
|
161
|
-
children.push(
|
|
162
|
-
React.createElement('div', {
|
|
163
|
-
key: 'bg-image',
|
|
164
|
-
className: 'background-image',
|
|
165
|
-
style: {
|
|
166
|
-
position: 'absolute',
|
|
167
|
-
inset: '0',
|
|
168
|
-
backgroundImage: `url(${siteUrl(img.src)})`,
|
|
169
|
-
backgroundPosition: img.position || 'center',
|
|
170
|
-
backgroundSize: img.size || 'cover',
|
|
171
|
-
backgroundRepeat: 'no-repeat',
|
|
172
|
-
},
|
|
173
|
-
'aria-hidden': 'true',
|
|
174
|
-
})
|
|
175
|
-
)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Overlay (gradient or solid)
|
|
179
|
-
if (background.overlay?.enabled) {
|
|
180
|
-
const ov = background.overlay
|
|
181
|
-
let overlayStyle
|
|
182
|
-
|
|
183
|
-
if (ov.gradient) {
|
|
184
|
-
const g = ov.gradient
|
|
185
|
-
overlayStyle = {
|
|
186
|
-
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
187
|
-
background: `linear-gradient(${g.angle || 180}deg, ${g.start || 'rgba(0,0,0,0.7)'} ${g.startPosition || 0}%, ${g.end || 'rgba(0,0,0,0)'} ${g.endPosition || 100}%)`,
|
|
188
|
-
opacity: ov.opacity ?? 0.5,
|
|
189
|
-
}
|
|
190
|
-
} else {
|
|
191
|
-
const baseColor = ov.type === 'light' ? '255, 255, 255' : '0, 0, 0'
|
|
192
|
-
overlayStyle = {
|
|
193
|
-
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
194
|
-
backgroundColor: `rgba(${baseColor}, ${ov.opacity ?? 0.5})`,
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
children.push(
|
|
199
|
-
React.createElement('div', {
|
|
200
|
-
key: 'bg-overlay',
|
|
201
|
-
className: ov.gradient ? 'background-overlay background-overlay--gradient' : 'background-overlay background-overlay--solid',
|
|
202
|
-
style: overlayStyle,
|
|
203
|
-
'aria-hidden': 'true',
|
|
204
|
-
})
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (children.length === 0) return null
|
|
209
|
-
|
|
210
|
-
return React.createElement('div', {
|
|
211
|
-
className: `background background--${background.mode}`,
|
|
212
|
-
style: containerStyle,
|
|
213
|
-
'aria-hidden': 'true',
|
|
214
|
-
}, ...children)
|
|
110
|
+
return React.createElement(Background, background)
|
|
215
111
|
}
|
|
216
112
|
|
|
217
113
|
/**
|
|
@@ -244,11 +140,20 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
|
|
|
244
140
|
const meta = getComponentMeta(block.type)
|
|
245
141
|
const entityStore = block.website?.entityStore
|
|
246
142
|
let entityData = null
|
|
143
|
+
let pending = false
|
|
247
144
|
if (entityStore) {
|
|
248
145
|
const resolved = entityStore.resolve(block, meta)
|
|
249
146
|
if (resolved.status === 'ready') entityData = resolved.data
|
|
147
|
+
pending = resolved.status === 'pending'
|
|
250
148
|
}
|
|
251
149
|
|
|
150
|
+
// ⭐ A block whose data is still out is LOADING here, as in the browser, which sets the same flag
|
|
151
|
+
// before `prepareProps` (`BlockRenderer.jsx`) and fetches the data once it boots. So the page
|
|
152
|
+
// paints the component's loading state, the one the browser renders in its place.
|
|
153
|
+
// ⛔ Until 2026-10-25 this was never set, and such a section prerendered its EMPTY state —
|
|
154
|
+
// "nothing found", or an error — then switched to loading when the browser took over.
|
|
155
|
+
block.dataLoading = pending
|
|
156
|
+
|
|
252
157
|
// Build content and params with runtime guarantees.
|
|
253
158
|
// prepareProps handles the full pipeline: entity data merge,
|
|
254
159
|
// foundation content handler invocation, guaranteed content
|
|
@@ -342,8 +247,8 @@ export function renderLayout(page, website) {
|
|
|
342
247
|
}
|
|
343
248
|
|
|
344
249
|
// Mirror Layout.jsx: wrap body + each area in a thin div carrying its
|
|
345
|
-
// view-transition-name, so the prerendered HTML matches
|
|
346
|
-
// and the browser can animate regions independently on client navigation.
|
|
250
|
+
// view-transition-name, so the prerendered HTML matches the page the SPA renders
|
|
251
|
+
// in its place and the browser can animate regions independently on client navigation.
|
|
347
252
|
const areaNames = Object.keys(areas)
|
|
348
253
|
const transitions = website.viewTransitions
|
|
349
254
|
? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
|
|
@@ -475,10 +380,9 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
|
|
|
475
380
|
}
|
|
476
381
|
|
|
477
382
|
// The same background a section gets, for kit's `SectionBackground` — a component that
|
|
478
|
-
// paints its own (`background: 'self'`).
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
383
|
+
// paints its own (`background: 'self'`). The one implementation, which the browser draws
|
|
384
|
+
// too (setup.js); its `className` is the layer's. ⛔ Until 2026-10-25 this dropped it.
|
|
385
|
+
uniweb.backgroundRenderer = Background
|
|
482
386
|
|
|
483
387
|
// L2 (singleton wiring): defaultInsets, xref.build(), and any future
|
|
484
388
|
// framework-level capability bridge — shared with setup.js so both
|
|
@@ -674,7 +578,7 @@ export { escapeHtml }
|
|
|
674
578
|
* Common operations shared by both build and cloud:
|
|
675
579
|
* - Replace #root div with rendered HTML
|
|
676
580
|
* - Update page title
|
|
677
|
-
* -
|
|
581
|
+
* - Write the page's head tags — description, canonical, hreflang, Open Graph (`writeHeadMeta`)
|
|
678
582
|
* - Inject section override CSS
|
|
679
583
|
* - Inject the pre-paint appearance script
|
|
680
584
|
*
|
|
@@ -701,7 +605,7 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
|
|
|
701
605
|
|
|
702
606
|
// Pre-paint appearance script. Prerendered HTML carries real content styled
|
|
703
607
|
// from :root (light) tokens, so a dark visitor would see a flash of light
|
|
704
|
-
// before the bundle
|
|
608
|
+
// before the bundle boots and applies the class. Read off the page's
|
|
705
609
|
// website back-ref rather than a parameter: every lane builds the same graph,
|
|
706
610
|
// so this needs no plumbing and no per-lane opt-in. Idempotent, because
|
|
707
611
|
// @uniweb/build's injectBuildData may run over this HTML afterwards.
|
|
@@ -766,45 +670,66 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
|
|
|
766
670
|
)
|
|
767
671
|
}
|
|
768
672
|
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// Social / SEO meta from the page's effective head metadata (page seo
|
|
780
|
-
// cascading over site-level seo — see Page.getHeadMeta). The SPA emits these
|
|
781
|
-
// client-side via useHeadMeta; this is the SSR twin, so crawlers and social
|
|
782
|
-
// unfurlers (which don't run JS) get them in the static HTML too.
|
|
783
|
-
const headMeta = page.getHeadMeta?.()
|
|
784
|
-
if (headMeta) {
|
|
785
|
-
const og = headMeta.og || {}
|
|
786
|
-
const keywords = Array.isArray(headMeta.keywords)
|
|
787
|
-
? headMeta.keywords.join(', ')
|
|
788
|
-
: headMeta.keywords
|
|
789
|
-
const tags = []
|
|
790
|
-
if (keywords) tags.push(`<meta name="keywords" content="${escapeHtml(keywords)}">`)
|
|
791
|
-
if (headMeta.robots) tags.push(`<meta name="robots" content="${escapeHtml(headMeta.robots)}">`)
|
|
792
|
-
if (og.title) tags.push(`<meta property="og:title" content="${escapeHtml(og.title)}">`)
|
|
793
|
-
if (og.description) tags.push(`<meta property="og:description" content="${escapeHtml(og.description)}">`)
|
|
794
|
-
if (og.image) tags.push(`<meta property="og:image" content="${escapeHtml(og.image)}">`)
|
|
795
|
-
if (og.url) tags.push(`<meta property="og:url" content="${escapeHtml(og.url)}">`)
|
|
796
|
-
tags.push('<meta property="og:type" content="website">')
|
|
797
|
-
tags.push(`<meta name="twitter:card" content="${og.image ? 'summary_large_image' : 'summary'}">`)
|
|
798
|
-
if (og.title) tags.push(`<meta name="twitter:title" content="${escapeHtml(og.title)}">`)
|
|
799
|
-
if (og.description) tags.push(`<meta name="twitter:description" content="${escapeHtml(og.description)}">`)
|
|
800
|
-
if (og.image) tags.push(`<meta name="twitter:image" content="${escapeHtml(og.image)}">`)
|
|
801
|
-
if (headMeta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(headMeta.canonical)}">`)
|
|
802
|
-
if (tags.length) result = result.replace('</head>', `${tags.join('\n')}\n</head>`)
|
|
803
|
-
}
|
|
673
|
+
// ⭐ THE PAGE'S HEAD TAGS — description, canonical, hreflang, Open Graph, Twitter — from the
|
|
674
|
+
// page's own head metadata (`Page#getHeadMeta`, which the browser writes the same tags from in
|
|
675
|
+
// `useHeadMeta`), so crawlers and social unfurlers, which run no script, read the page's own.
|
|
676
|
+
// ⛔ Until 2026-10-25 these were APPENDED, after the static build had written the HOME page's
|
|
677
|
+
// into the one shell every page is rendered into: each page named the home page its canonical
|
|
678
|
+
// and carried two og:title and two og:url, the home page's first.
|
|
679
|
+
result = writeHeadMeta(result, page)
|
|
804
680
|
|
|
805
681
|
return result
|
|
806
682
|
}
|
|
807
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Write a page's head tags into `html`'s head. These tags are the framework's: each is set to the
|
|
686
|
+
* page's value, replacing any of its name the shell carries, and removed when the page has none —
|
|
687
|
+
* as the browser does on every navigation (`useHeadMeta`), so hydrating the page changes nothing a
|
|
688
|
+
* crawler read.
|
|
689
|
+
*
|
|
690
|
+
* @param {string} html
|
|
691
|
+
* @param {Object} page
|
|
692
|
+
* @returns {string}
|
|
693
|
+
*/
|
|
694
|
+
function writeHeadMeta(html, page) {
|
|
695
|
+
// A Page states its own (`getHeadMeta`); plain page data, which callers may hand in, its fields.
|
|
696
|
+
const meta = typeof page?.getHeadMeta === 'function' ? page.getHeadMeta() : { description: page?.description || null }
|
|
697
|
+
const end = html.indexOf('</head>')
|
|
698
|
+
if (!meta || end === -1) return html
|
|
699
|
+
const og = meta.og || {}
|
|
700
|
+
const keywords = Array.isArray(meta.keywords) ? meta.keywords.join(', ') : meta.keywords
|
|
701
|
+
const metaTags = [
|
|
702
|
+
['name', 'description', meta.description],
|
|
703
|
+
['name', 'keywords', keywords],
|
|
704
|
+
['name', 'robots', meta.robots],
|
|
705
|
+
['property', 'og:type', 'website'],
|
|
706
|
+
['property', 'og:site_name', og.siteName],
|
|
707
|
+
['property', 'og:title', og.title],
|
|
708
|
+
['property', 'og:description', og.description],
|
|
709
|
+
['property', 'og:image', og.image],
|
|
710
|
+
['property', 'og:url', og.url],
|
|
711
|
+
['name', 'twitter:card', og.image ? 'summary_large_image' : 'summary'],
|
|
712
|
+
['name', 'twitter:site', meta.twitter?.site],
|
|
713
|
+
['name', 'twitter:title', og.title],
|
|
714
|
+
['name', 'twitter:description', og.description],
|
|
715
|
+
['name', 'twitter:image', og.image],
|
|
716
|
+
]
|
|
717
|
+
// The head alone: the page's content is in the body, and none of it is edited here.
|
|
718
|
+
let head = html.slice(0, end)
|
|
719
|
+
const written = []
|
|
720
|
+
for (const [attr, key, value] of metaTags) {
|
|
721
|
+
head = head.replace(new RegExp(`<meta\\b[^>]*\\b(?:name|property)=["']${key}["'][^>]*>\\s*`, 'gi'), '')
|
|
722
|
+
if (value) written.push(`<meta ${attr}="${key}" content="${escapeHtml(value)}">`)
|
|
723
|
+
}
|
|
724
|
+
head = head.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>\s*/gi, '')
|
|
725
|
+
if (meta.canonical) written.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}">`)
|
|
726
|
+
head = head.replace(/<link\b[^>]*\bhreflang=["'][^"']*["'][^>]*>\s*/gi, '')
|
|
727
|
+
for (const { hreflang, url } of meta.alternates || []) {
|
|
728
|
+
written.push(`<link rel="alternate" hreflang="${escapeHtml(hreflang)}" href="${escapeHtml(url)}">`)
|
|
729
|
+
}
|
|
730
|
+
return `${head}${written.join('\n')}\n${html.slice(end)}`
|
|
731
|
+
}
|
|
732
|
+
|
|
808
733
|
// ============================================================================
|
|
809
734
|
// 404 fallback generation
|
|
810
735
|
// ============================================================================
|
package/src/sticky-check.js
CHANGED
|
@@ -4,13 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `position: sticky` keeps an element inside its parent's box. When the parent is no taller than
|
|
6
6
|
* the element (no wider, for one that sticks sideways), the element has no room and scrolls away
|
|
7
|
-
* with the page, and nothing says so.
|
|
7
|
+
* with the page, and nothing says so. Whose box the parent is says what to do (`unmovableAdvice`):
|
|
8
8
|
*
|
|
9
|
-
* - a component's own `sticky top-0
|
|
10
|
-
* never moves. The section's `sticky: true` asks the runtime
|
|
11
|
-
* (`sticky.js`).
|
|
12
|
-
* - a
|
|
13
|
-
* The layout makes its element sticky when `page.isSticky(area)`.
|
|
9
|
+
* - a component's outermost element — a header's own `sticky top-0` — sits in the section's
|
|
10
|
+
* wrapper, as tall as itself, so it never moves. The section's `sticky: true` asks the runtime
|
|
11
|
+
* to make sticky the box that can (`sticky.js`).
|
|
12
|
+
* - a box the runtime made sticky (`data-uw-sticky`) in an element a layout wraps it in, which
|
|
13
|
+
* confines it. The layout makes its element sticky when `page.isSticky(area)`.
|
|
14
|
+
* - an element inside a component sits in a box the component draws. A side panel beside a list
|
|
15
|
+
* sticks within the row the two share, and cannot when its box is only as tall as the panel:
|
|
16
|
+
* the fix is the component's, to give that box room or make it the sticky one.
|
|
17
|
+
*
|
|
18
|
+
* ⭐ MEASURED ON THE SETTLED PAGE. A box's height can wait on the page's data — a list fills its
|
|
19
|
+
* column once its records arrive — so the check runs once nothing is in flight (the layout passes
|
|
20
|
+
* `website.dataStore.whenIdle` as `settled`), after the page's fonts, in the frames after that:
|
|
21
|
+
* React has committed what arrived, and the browser has laid it out. ⛔ Until 2026-10-25 it
|
|
22
|
+
* measured in the first frame after the page mounted, while the page's fetches were still out,
|
|
23
|
+
* and named a side panel whose column was waiting for its records as one that could never move.
|
|
14
24
|
*
|
|
15
25
|
* Table parts are left out: a sticky table header cell sticks within its table, though its row is
|
|
16
26
|
* no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
|
|
@@ -25,6 +35,22 @@
|
|
|
25
35
|
|
|
26
36
|
const warned = new WeakSet()
|
|
27
37
|
|
|
38
|
+
/** Every section wrapper the browser's renderer drew, noted in dev (`BlockRenderer.jsx`). */
|
|
39
|
+
const sectionWrappers = new WeakSet()
|
|
40
|
+
|
|
41
|
+
/** How long the check waits on the page's fetches before it measures anyway. */
|
|
42
|
+
const SETTLE_CAP_MS = 10_000
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Note a section's wrapper, so the check can tell a component's outermost element — the one in
|
|
46
|
+
* it — from an element inside a component. A ref callback's argument: null on unmount.
|
|
47
|
+
*
|
|
48
|
+
* @param {Element|null} element
|
|
49
|
+
*/
|
|
50
|
+
export function noteSectionWrapper(element) {
|
|
51
|
+
if (element) sectionWrappers.add(element)
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
/** The nearest ancestor that draws a box — `display: contents` draws none. */
|
|
29
55
|
function boxParent(el) {
|
|
30
56
|
let parent = el.parentElement
|
|
@@ -73,38 +99,85 @@ export function clippingAncestor(el) {
|
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
/**
|
|
76
|
-
*
|
|
102
|
+
* What to do about a sticky element that cannot move, by whose box holds it (see above).
|
|
77
103
|
*
|
|
78
|
-
* @param {
|
|
79
|
-
* @returns {
|
|
104
|
+
* @param {Element} el
|
|
105
|
+
* @returns {string}
|
|
80
106
|
*/
|
|
81
|
-
export function
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
export function unmovableAdvice(el) {
|
|
108
|
+
if (el.getAttribute('data-uw-sticky')) {
|
|
109
|
+
return (
|
|
110
|
+
'It is the box the runtime makes sticky for a section, and an element of the layout around it ' +
|
|
111
|
+
'confines it: a layout that wraps an area in an element of its own makes that element sticky ' +
|
|
112
|
+
'when `page.isSticky(area)`.'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (sectionWrappers.has(boxParent(el))) {
|
|
116
|
+
return (
|
|
117
|
+
"It is a section's outermost element, and the section's wrapper is as tall as it is. To keep the " +
|
|
118
|
+
'section in view, give it `sticky: true` (its type may default it in meta.js), and the runtime ' +
|
|
119
|
+
'makes sticky the box that can.'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return (
|
|
123
|
+
'It sits in a box the component draws. Give that box room — let it stretch with its row rather ' +
|
|
124
|
+
'than fit its content (`items-start` or `self-start` shrinks it) — or make that box the sticky one.'
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Warn, once per element, about each sticky element on the page that cannot move. */
|
|
129
|
+
function checkPage(doc) {
|
|
130
|
+
for (const el of doc.body.querySelectorAll('*')) {
|
|
131
|
+
if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
|
|
132
|
+
const clip = clippingAncestor(el)
|
|
133
|
+
if (clip) {
|
|
99
134
|
warned.add(el)
|
|
100
135
|
console.warn(
|
|
101
|
-
'[uniweb] This element is sticky but
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'wraps an area in an element of its own makes that element sticky when `page.isSticky(area)`.',
|
|
136
|
+
'[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
|
|
137
|
+
'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
|
|
138
|
+
'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
|
|
105
139
|
el,
|
|
140
|
+
clip,
|
|
106
141
|
)
|
|
142
|
+
continue
|
|
107
143
|
}
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
|
|
145
|
+
warned.add(el)
|
|
146
|
+
console.warn(
|
|
147
|
+
'[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
|
|
148
|
+
`scrolls away with the page. ${unmovableAdvice(el)}`,
|
|
149
|
+
el,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check the page once it has settled: when `settled()` resolves (or after `SETTLE_CAP_MS`, so a
|
|
156
|
+
* fetch that never answers does not silence the check), its fonts have loaded, and two frames have
|
|
157
|
+
* passed — the first for React to commit what arrived, the second to lay it out.
|
|
158
|
+
*
|
|
159
|
+
* @param {Document} [doc]
|
|
160
|
+
* @param {{ settled?: () => Promise<unknown> }} [options] - what the page waits on: its data
|
|
161
|
+
* @returns {() => void} cancels a check not yet run
|
|
162
|
+
*/
|
|
163
|
+
export function warnUnmovableSticky(doc = globalThis.document, { settled } = {}) {
|
|
164
|
+
if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
|
|
165
|
+
let cancelled = false
|
|
166
|
+
let frame = 0
|
|
167
|
+
let cap = 0
|
|
168
|
+
const nextFrame = () => new Promise((resolve) => { frame = requestAnimationFrame(resolve) })
|
|
169
|
+
const capped = new Promise((resolve) => { cap = setTimeout(resolve, SETTLE_CAP_MS) })
|
|
170
|
+
;(async () => {
|
|
171
|
+
await Promise.race([Promise.resolve(settled?.()).catch(() => {}), capped])
|
|
172
|
+
clearTimeout(cap)
|
|
173
|
+
await doc.fonts?.ready
|
|
174
|
+
if (!cancelled) await nextFrame()
|
|
175
|
+
if (!cancelled) await nextFrame()
|
|
176
|
+
if (!cancelled) checkPage(doc)
|
|
177
|
+
})()
|
|
178
|
+
return () => {
|
|
179
|
+
cancelled = true
|
|
180
|
+
clearTimeout(cap)
|
|
181
|
+
cancelAnimationFrame(frame)
|
|
182
|
+
}
|
|
110
183
|
}
|
package/src/sticky.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* (`components/Layout.jsx`, `BlockRenderer.jsx`) and the prerender's (`ssr-renderer.js`) make the
|
|
4
4
|
* same boxes sticky, and a prerendered page does not change when the browser renders over it.
|
|
5
5
|
*
|
|
6
|
-
* ⭐
|
|
7
|
-
* parent's box, and a component
|
|
8
|
-
*
|
|
6
|
+
* ⭐ A COMPONENT'S OUTERMOST ELEMENT CANNOT STICK. `position: sticky` moves an element only within
|
|
7
|
+
* its parent's box, and a component sits in boxes as tall as itself: the section's wrapper, the
|
|
8
|
+
* layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`). (An
|
|
9
|
+
* element inside a component can stick within a taller box the component draws — a side panel in
|
|
10
|
+
* the row beside a list — which is the component's own layout and asks nothing of this module.)
|
|
9
11
|
* So a section asks — `sticky:`, one of a section's own settings (`@uniweb/schemas/section`),
|
|
10
12
|
* defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
|
|
11
13
|
*
|