@uniweb/runtime 1.1.20 → 1.2.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.
@@ -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. The two ways it happens:
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`: a section's markup sits in boxes as tall as itself, so it
10
- * never moves. The section's `sticky: true` asks the runtime to make sticky the box that can
11
- * (`sticky.js`).
12
- * - a layout that wraps a sticky area in an element of its own, which confines the area's box.
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
- * Warn, once per element, about each sticky element on the page that cannot move.
102
+ * What to do about a sticky element that cannot move, by whose box holds it (see above).
77
103
  *
78
- * @param {Document} [doc]
79
- * @returns {() => void} cancels a check not yet run
104
+ * @param {Element} el
105
+ * @returns {string}
80
106
  */
81
- export function warnUnmovableSticky(doc = globalThis.document) {
82
- if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
83
- const frame = requestAnimationFrame(() => {
84
- for (const el of doc.body.querySelectorAll('*')) {
85
- if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
86
- const clip = clippingAncestor(el)
87
- if (clip) {
88
- warned.add(el)
89
- console.warn(
90
- '[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
91
- 'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
92
- 'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
93
- el,
94
- clip,
95
- )
96
- continue
97
- }
98
- if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
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 cannot move: its parent is no taller than it is, so it ' +
102
- 'scrolls away with the page. To keep a section in view, give the section `sticky: true` ' +
103
- '(its type may default it in meta.js) — no box a component draws can stick. A layout that ' +
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
- return () => cancelAnimationFrame(frame)
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
- * ⭐ NO BOX A COMPONENT DRAWS CAN STICK. `position: sticky` moves an element only within its
7
- * parent's box, and a component's markup sits in boxes as tall as itself: the section's wrapper,
8
- * the layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`).
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
  *