react-x11 2.51.0 → 2.52.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/README.md +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/xkb.js +137 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# Container queries: what the window queries already answer, and what a per-node one would cost
|
|
2
|
+
|
|
3
|
+
_Research document, 2026-09-06. Written against the `master` checkout at c9ce902
|
|
4
|
+
(react-x11 2.6.1; `src/nodes.js` @ 12,354 lines, `src/styles.js` @ 1,694). The
|
|
5
|
+
one measurement is a yoga-only spike (§7), run in-process on one machine (Apple
|
|
6
|
+
M1 Pro, node 26); nothing here was run against an X server, because nothing
|
|
7
|
+
here needs one. file:line references are to that checkout and will drift._
|
|
8
|
+
|
|
9
|
+
_Implemented after this was written, along the lines of §5 and §8:
|
|
10
|
+
[styling.md](../styling.md#container-queries) is the reference and
|
|
11
|
+
`examples/container-queries.jsx` the demonstration._
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 0. TL;DR
|
|
16
|
+
|
|
17
|
+
**Feasible, and most of the machinery already exists.** A window size query
|
|
18
|
+
(`'@width >= 600'`, [styling.md](../styling.md#window-size-queries)) and a
|
|
19
|
+
container query differ in exactly one structural way: a window's size is an
|
|
20
|
+
_input_ to layout and a container's size is an _output_ of it. So a container
|
|
21
|
+
query cannot be resolved where the window queries are resolved — before the
|
|
22
|
+
pass — but has to be resolved _after_ a pass, and the tree laid out once more
|
|
23
|
+
if any answer changed. That loop is not new either: it is what `realize()`
|
|
24
|
+
already runs for an auto-sized window that carries `@width` blocks
|
|
25
|
+
(`nodes.js:9480-9503`, the "measure, re-resolve, measure once more" rule).
|
|
26
|
+
Everything else — the grammar, the per-window registry, per-node
|
|
27
|
+
re-resolution, pushing a style diff into yoga, the layout diff that turns a
|
|
28
|
+
reflow into damage — is reusable as it stands.
|
|
29
|
+
|
|
30
|
+
Findings, compressed:
|
|
31
|
+
|
|
32
|
+
1. **The cost is bounded and small.** After a pass, reading the size of 300
|
|
33
|
+
registered containers costs 0.04 ms; a second yoga pass costs in proportion
|
|
34
|
+
to the subtrees whose style actually changed — 0.46 ms when one card's
|
|
35
|
+
block flips, 3.0 ms when all 300 do (≈ 1.7× a window-resize pass), on a
|
|
36
|
+
3,600-node tree. Frames where no answer crosses a threshold — every scroll
|
|
37
|
+
notch, every hover, most resize frames — pay the reads and nothing else
|
|
38
|
+
(§6).
|
|
39
|
+
|
|
40
|
+
2. **The cycle is the real design question, and it is a detection problem
|
|
41
|
+
here rather than a prevention one.** CSS makes a container query safe by
|
|
42
|
+
_containment_: `container-type: inline-size` computes the container's
|
|
43
|
+
inline size as if it had no content, so nothing a descendant does can move
|
|
44
|
+
it. Yoga has no containment. It does have three constructions where a
|
|
45
|
+
node's size is already independent of its subtree (an explicit size, a
|
|
46
|
+
flex item with its content floor off, a scroller) and one clean signal
|
|
47
|
+
when a design is unsatisfiable: the second look disagrees with the first.
|
|
48
|
+
Settle once, pin the answer, and warn naming the node (§3).
|
|
49
|
+
|
|
50
|
+
3. **On targeting** (§4): the recommendation is CSS's rule — the **nearest
|
|
51
|
+
ancestor that declares itself a container**, with an optional **name** to
|
|
52
|
+
skip past nearer ones — as one grammar, `'@container [name] width >= 400'`,
|
|
53
|
+
with the declaration a _style property_ (`container: true | 'sidebar'`).
|
|
54
|
+
**Direct parent** is rejected as a grammar (one wrapper `<box>` changes the
|
|
55
|
+
answer silently, which is the exact failure that made CSS abandon element
|
|
56
|
+
queries), though `container: true` on the parent spells the same thing
|
|
57
|
+
explicitly. **Self** queries are possible only paint-only and are deferred.
|
|
58
|
+
Making **scrollers implicit containers** is rejected in favour of
|
|
59
|
+
documenting them as the ideal thing to declare one on. And a general
|
|
60
|
+
`onLayout` on any `<box>` — today only a scroller has `onViewport` — is the
|
|
61
|
+
React-side seam that should ship first regardless.
|
|
62
|
+
|
|
63
|
+
## 1. What exists: the window size queries
|
|
64
|
+
|
|
65
|
+
The pieces, because the design below is mostly "the same, later":
|
|
66
|
+
|
|
67
|
+
- **Grammar and resolution** (`styles.js`). `SIZE_QUERY` (`:479`) parses
|
|
68
|
+
`@width|@height op number`; `parseQuery` (`:498`) memoizes per key;
|
|
69
|
+
`resolveQueries(style, { size, supports })` (`:561`) merges every matching
|
|
70
|
+
block in declaration order over the base and returns the base itself when
|
|
71
|
+
nothing matches, so the identity fast path survives. `validateStyle`
|
|
72
|
+
(`:602`) makes a malformed key an error at `createStyles` time, and a query
|
|
73
|
+
block — unlike a state block — may carry layout properties, because it is
|
|
74
|
+
only ever re-evaluated inside a layout pass a resize already required.
|
|
75
|
+
- **Per-node registration** (`nodes.js`). `_syncStyle` (`:1975`) registers a
|
|
76
|
+
node with its window's `_sizeQueryNodes` when `styleHasSizeQueries`
|
|
77
|
+
(`:2000-2007`) and folds the matching blocks into `_baseStyle` _before_ the
|
|
78
|
+
state blocks (`:2036-2042`), so a `:hover` inside the wide layout still
|
|
79
|
+
wins over the wide layout. `_registerSizeQueries` (`:2391`) is the recursive
|
|
80
|
+
hook `insertBefore` runs so a subtree attached after a resize matches
|
|
81
|
+
against the size the window is now.
|
|
82
|
+
- **Per-node re-resolution.** `_sizeQueriesChanged` (`:2708`) is the whole of
|
|
83
|
+
it: `_syncStyle` again, `localTextStyleChanged` for the node-local text
|
|
84
|
+
props, and `applyLayoutStyle(yoga, style, before)` (`styles.js:1543`) to
|
|
85
|
+
push only the layout properties that moved into yoga — which dirties the
|
|
86
|
+
yoga node, so the next pass re-lays out that subtree and nothing else.
|
|
87
|
+
- **The window's trigger.** `_resolveSizeQueries(deviceW, deviceH)`
|
|
88
|
+
(`:10987`) divides by the scale, compares with the last `querySize`,
|
|
89
|
+
re-resolves every registered node and marks the content floors dirty
|
|
90
|
+
(`_floorsDirty`). It runs from `flush()` before `_layoutRoot` (`:11385`),
|
|
91
|
+
and twice inside `realize()`'s auto-size `measure()` loop (`:9488`,
|
|
92
|
+
`:9503`) — the second time _after_ a measurement, re-measuring once if the
|
|
93
|
+
answer moved. That second call is a container query in everything but name:
|
|
94
|
+
it resolves blocks against a size the layout produced.
|
|
95
|
+
- **Damage.** `_absolutizeChildren` (`:11416`) runs once after whatever
|
|
96
|
+
layout passes the frame needed, and `_assignAbs` (`:3719`) claims the old
|
|
97
|
+
and new rect of every node the pass moved through `layoutDiffSink`. Any
|
|
98
|
+
number of yoga passes before that walk cost nothing in damage terms.
|
|
99
|
+
|
|
100
|
+
One property of the current path is worth naming because the container path
|
|
101
|
+
must not inherit it: every registered node re-runs `_syncStyle` whenever the
|
|
102
|
+
window's logical size changes at all (`:11002`), not only when one of _its_
|
|
103
|
+
blocks changed answer. At window-resize frequency that is fine. A container's
|
|
104
|
+
size can move on far more frames than a window's, so the container path has
|
|
105
|
+
to gate the re-sync on an answer actually flipping (§5).
|
|
106
|
+
|
|
107
|
+
## 2. What is different about a container
|
|
108
|
+
|
|
109
|
+
Four things, and only the first is structural:
|
|
110
|
+
|
|
111
|
+
1. **Input versus output.** The window's size is known before the pass; a
|
|
112
|
+
container's size is what the pass produces. Resolution moves to _after_
|
|
113
|
+
`_layoutRoot`, and a changed answer costs one more pass.
|
|
114
|
+
2. **A target.** Which ancestor. The window query has one implicit target per
|
|
115
|
+
tree; a container query needs a rule for finding its container (§4) and a
|
|
116
|
+
cache for the answer, dropped when the tree above the node changes — the
|
|
117
|
+
shape `direction` already has (`:2482`, resolved nearest-first through
|
|
118
|
+
`parent`, cached, invalidated by `_redirectSubtree`).
|
|
119
|
+
3. **Many registries.** One `_sizeQueryNodes` per window becomes a set of
|
|
120
|
+
dependents per window, each with a pointer to the container(s) it asks
|
|
121
|
+
about, and a remembered last answer per block.
|
|
122
|
+
4. **The cycle** (§3). A window cannot be moved by the styles inside it; a
|
|
123
|
+
container can.
|
|
124
|
+
|
|
125
|
+
## 3. The cycle
|
|
126
|
+
|
|
127
|
+
CSS's answer is `container-type: inline-size`: size containment on that axis,
|
|
128
|
+
so the container is sized as if empty and no descendant's style can feed back
|
|
129
|
+
into the size the descendants are querying. Yoga has no containment. What it
|
|
130
|
+
has instead are the three constructions under which a node's size on an axis
|
|
131
|
+
is _already_ independent of its subtree:
|
|
132
|
+
|
|
133
|
+
- an explicit `width` / `height`;
|
|
134
|
+
- a flex item whose size the parent distributes, with its **content floor
|
|
135
|
+
off** — `minWidth: 0` plus a fixed `flexBasis` (or `flex: 1`, which expands
|
|
136
|
+
to basis `0`). The automatic minimum is the one route by which content
|
|
137
|
+
feeds back into a flex item's size, and `minWidth: 0` is its documented
|
|
138
|
+
switch ([elements.md](../elements.md#a-floor-the-content-decides));
|
|
139
|
+
- a **scroller**: `overflow: 'scroll'` implies `minWidth: 0`,
|
|
140
|
+
`minHeight: 0` and — when it grows — `flexBasis: 0`
|
|
141
|
+
(`styles.js:1343`, `resolveComputedStyle`). Content overflows a scroller
|
|
142
|
+
rather than growing it, which is containment in everything but name. It is
|
|
143
|
+
the natural container, and the docs should say so.
|
|
144
|
+
|
|
145
|
+
Where none of those hold, the design can be unsatisfiable. The spike's last
|
|
146
|
+
section (§7) shows the shape: a card at 230 px whose block, below 250, sets a
|
|
147
|
+
`flexBasis` that makes it 310, whose block above 250 sets one that makes it
|
|
148
|
+
230 — and so on for as many passes as one is willing to run. No number of
|
|
149
|
+
passes settles it, and no size satisfies it.
|
|
150
|
+
|
|
151
|
+
Three ways to live with that:
|
|
152
|
+
|
|
153
|
+
| option | verdict |
|
|
154
|
+
| ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| **(i) Settle once, pin, warn.** Re-lay out once; if a second look disagrees, keep the answer and say so in development. | **Recommended.** Mirrors `realize()`'s rule. CSS makes the bad case impossible; this makes it _visible_, with the container, the dependent and the axis named. The common responsive pattern (less content when narrow) is monotone and settles on the first re-layout, so a warning means a genuine design contradiction. |
|
|
156
|
+
| **(ii) Paint-only container blocks**, like state blocks. | No cycle can form, but the main use case — a column becoming a row, a sidebar folding away — is layout. Rejected as the default; it is the right rule for _self_ queries (§4 D). |
|
|
157
|
+
| **(iii) A development-time check that the queried axis is pinned** by one of the three constructions above. | Rejected: it is a heuristic (a parent with an explicit width pins a child's row too, invisibly to a per-node check), it would refuse designs that settle, and the runtime detection in (i) is exact. |
|
|
158
|
+
|
|
159
|
+
The pinning rule in (i) needs one refinement, or it strobes. Take a frame
|
|
160
|
+
that starts with blocks resolved as B₀ and lays out to size S₁. Resolving
|
|
161
|
+
against S₁ gives B₁. If B₁ = B₀, done — the common case, and no extra pass.
|
|
162
|
+
Otherwise apply B₁ and lay out again, to S₂; resolve to B₂. If B₂ = B₁,
|
|
163
|
+
settled at one extra pass. If not, the design oscillates — and _leaving it
|
|
164
|
+
there_ means the next frame's first pass starts from B₁/S₂, finds B₂, applies
|
|
165
|
+
it, gets S₁, finds B₁ again, and every frame that runs layout flips the block.
|
|
166
|
+
The fix is to remember the pair (S₂, B₁) on the dependent and hold B₁ while
|
|
167
|
+
the first pass keeps producing S₂; the query re-evaluates when the container's
|
|
168
|
+
size moves for some _other_ reason. A few lines, and the warning fires once.
|
|
169
|
+
|
|
170
|
+
Nested containers need the cap stated correctly too. If an outer container's
|
|
171
|
+
dependent flips, the second pass can legitimately move an _inner_ container
|
|
172
|
+
past one of its own thresholds — a cascade, not an oscillation. So the loop
|
|
173
|
+
runs up to a small fixed number of passes (three is enough for any nesting
|
|
174
|
+
seen in the examples), and "oscillation" is defined as a dependent returning
|
|
175
|
+
to an answer it already held _this frame_, which is exact where "changed on
|
|
176
|
+
the second look" would pin a cascade wrongly.
|
|
177
|
+
|
|
178
|
+
## 4. Targeting the container
|
|
179
|
+
|
|
180
|
+
The question this document was asked. Each row is a way for a style to say
|
|
181
|
+
_which_ box it is asking about.
|
|
182
|
+
|
|
183
|
+
| how it reads | setup | survives a wrapper `<box>`? | lookup | cycle exposure | CSS transfer |
|
|
184
|
+
| -------------------------------------------------------------- | -------------------------------------- | ------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------- | ----------------------------------------------------- |
|
|
185
|
+
| **A. nearest declared ancestor** — `'@container width >= 400'` | `container: true` on the ancestor | yes | walk `parent` to the first declaring node; cached like `direction` | as §3 | `@container (min-width: 400px)` |
|
|
186
|
+
| **B. named** — `'@container sidebar width >= 400'` | `container: 'sidebar'` on the ancestor | yes | same walk, filtered by name; skips past nearer containers | as §3 | `container-name` / `@container sidebar (…)` |
|
|
187
|
+
| **C. direct parent** — `'@parent width >= 400'` | none | **no** | one property read | as §3 | none — this is the element-query proposal CSS dropped |
|
|
188
|
+
| **D. self** — `'@self width >= 400'` | none | n/a | none | the self-cycle — safe only if the block is **paint-only** | none |
|
|
189
|
+
| **E. nearest scroller, implicitly** | none | yes, unless the wrapper scrolls | walk `parent` to the first `overflow: 'scroll'` | none — a scroller's size is content-independent | none |
|
|
190
|
+
| **F. React-side** — `onLayout` + context, `useContainerSize()` | a component around the subtree | yes | none in the renderer | none (it is a state update) | `ResizeObserver` + a hook |
|
|
191
|
+
| **G. by kind or role** — `'@container [role=list] …'` | none | yes | walk with a predicate | as §3 | relational selectors |
|
|
192
|
+
|
|
193
|
+
**Recommendation: A and B as one grammar; F beside them as the seam; D
|
|
194
|
+
deferred; C, E and G rejected.** The reasons, one per row:
|
|
195
|
+
|
|
196
|
+
- **A** is CSS's default and the one that survives composition. A component
|
|
197
|
+
that adapts to where it is put must not have its answer changed by the
|
|
198
|
+
consumer wrapping it in a `<box>` for a border, and an explicit declaration
|
|
199
|
+
on the container is the only thing that guarantees that.
|
|
200
|
+
- **B** exists for the case A gets wrong: a component that is itself a
|
|
201
|
+
container for its own rows (a card sizing its columns) would otherwise
|
|
202
|
+
capture every unnamed query inside it, including the ones its children
|
|
203
|
+
meant for the page. A name lets a query reach past. The one decision B
|
|
204
|
+
forces is what an **unmatched name** does. CSS's rule is that the query
|
|
205
|
+
simply does not apply, and that is the right one here too — it is what lets
|
|
206
|
+
the same component render inside and outside the `sidebar` — even though it
|
|
207
|
+
means a typo in a name is not an error. Worth a line in the debugging
|
|
208
|
+
guide; not worth making the component unusable outside its context.
|
|
209
|
+
An **unnamed** query with no declared container above it, on the other
|
|
210
|
+
hand, should be a development-time **error**: CSS silently falls back to
|
|
211
|
+
the viewport there, but this codebase already has a spelling for the window
|
|
212
|
+
(`'@width'`), so the fallback would only ever hide a forgotten declaration.
|
|
213
|
+
- **C** reads as the least setup, and it is the closest to "adapt to the slot
|
|
214
|
+
I was put in" — which is the point of the feature. But the parent of a
|
|
215
|
+
reusable component is whatever its consumer rendered, and the moment that
|
|
216
|
+
consumer adds a wrapper for padding or a border the query is asking a
|
|
217
|
+
different box a question with a different answer, with nothing in the
|
|
218
|
+
program having changed its mind. CSS went through exactly this — element
|
|
219
|
+
queries were proposed for a decade and never shipped, and container
|
|
220
|
+
queries shipped with a required declaration. A with `container: true` on
|
|
221
|
+
the parent is C spelled so that it cannot drift.
|
|
222
|
+
- **D** is the one shape CSS cannot offer at all, because of the self-cycle,
|
|
223
|
+
and this codebase _could_: the paint-only restriction state blocks already
|
|
224
|
+
live under (`STATE_PROPS`, `styles.js:429`) is exactly the restriction that
|
|
225
|
+
makes a self query safe. The use cases are thin — a card muting its title
|
|
226
|
+
when narrow, a border that drops at a small size — so it is a possible
|
|
227
|
+
later addition, not part of this design.
|
|
228
|
+
- **E** is tempting because the scroller is the one place a cycle cannot
|
|
229
|
+
form (§3). But _implicit_ containers mean the presence of a scroller
|
|
230
|
+
between a node and its intended container changes which box answers, with
|
|
231
|
+
no declaration anywhere to point at. Explicit wins; the docs should
|
|
232
|
+
recommend declaring `container` on the scroller.
|
|
233
|
+
- **F** is not a competitor but the seam ("decide it, then leave a way out",
|
|
234
|
+
AGENTS.md). Decisions that are not styles — how many columns to
|
|
235
|
+
virtualize, which component to render at all — need a size in React, and
|
|
236
|
+
today only a scroller offers one (`onViewport`, `nodes.js:6216`, fired from
|
|
237
|
+
layout via `setImmediate`). Generalizing it to `onLayout` on any `<box>` is
|
|
238
|
+
small: `_assignAbs` already detects the size change; it lacks only the
|
|
239
|
+
deferred notification. What F cannot do is what makes the renderer-native
|
|
240
|
+
query worth having: its answer lands a **frame late**. A size change
|
|
241
|
+
reaches React after the commit that caused it, React re-renders, a second
|
|
242
|
+
commit re-lays out — one frame drawn at the wrong layout on every threshold
|
|
243
|
+
crossing during a drag. A renderer query resolves inside the same flush and
|
|
244
|
+
is never seen wrong.
|
|
245
|
+
- **G** is a relational selector, which styling.md's "Decided" section rules
|
|
246
|
+
out for the whole style system: inheritance and node state, no matching
|
|
247
|
+
against other nodes.
|
|
248
|
+
|
|
249
|
+
**Where the declaration lives.** As a **style property**, not an element
|
|
250
|
+
prop. AGENTS.md draws the line — props carry element semantics, `style`
|
|
251
|
+
carries the CSS-like vocabulary — and `container` is CSS vocabulary
|
|
252
|
+
(`container-type`/`container-name`). It also has to be settable from _inside_
|
|
253
|
+
a query block (a box that becomes a container only in the wide layout), which
|
|
254
|
+
only a style property can be. It joins `STYLE_PROPS` beside `cursor` and
|
|
255
|
+
`hitSlop` as a property that is neither yoga nor paint, and DevTools' style
|
|
256
|
+
editor lists it for free through `STYLE_PROP_NAMES`.
|
|
257
|
+
|
|
258
|
+
**Units.** A threshold is in logical pixels like every other length in the
|
|
259
|
+
block, and it should be divided out by the **dependent's** `scale`, not the
|
|
260
|
+
container's: the author wrote `'@container width >= 400'` next to their own
|
|
261
|
+
`width: 400`, and the two numbers must mean the same thing. The two scales
|
|
262
|
+
differ only when the dependent adds a `scale` of its own under the container,
|
|
263
|
+
which is rare and then reads as CSS's rule that query lengths resolve in the
|
|
264
|
+
querying element's context.
|
|
265
|
+
|
|
266
|
+
## 5. Mechanism, at the hook points
|
|
267
|
+
|
|
268
|
+
`styles.js`:
|
|
269
|
+
|
|
270
|
+
- `CONTAINER_QUERY = /^@container(?:\s+([A-Za-z_][\w-]*))?\s+(width|height)\s*(>=|<=|>|<)\s*(\d+(?:\.\d+)?)$/`,
|
|
271
|
+
parsed into `{ kind: 'container', name, axis, op, value }` by `parseQuery`;
|
|
272
|
+
`sizeMatches` is reused unchanged. `validateStyle`'s message grows a third
|
|
273
|
+
example. `styleHasContainerQueries` joins the two existing `hasQueryOfKind`
|
|
274
|
+
predicates, and `resolveQueries` takes a third option, `containers`: a map
|
|
275
|
+
from name (`''` for unnamed) to the container's logical size, built per
|
|
276
|
+
dependent by the node. `resolveQueries` is public through `react-x11/style`;
|
|
277
|
+
widening its options object is the shape it already has.
|
|
278
|
+
- `container` joins `STYLE_PROPS`, validated as `true | string`.
|
|
279
|
+
|
|
280
|
+
`nodes.js`, on `Node`:
|
|
281
|
+
|
|
282
|
+
- `_syncStyle` records `_containerQueried` and the set of names the style asks
|
|
283
|
+
about, and registers the node with `root._containerQueryNodes`, exactly as
|
|
284
|
+
`_queried` does today (`:2000-2007`). `_registerSizeQueries` (`:2391`)
|
|
285
|
+
re-registers it on insert.
|
|
286
|
+
- `_containerFor(name)`: walk `parent` to the first node whose `style.container`
|
|
287
|
+
matches (`true` for unnamed, the string for named), cached on the node and
|
|
288
|
+
dropped when the node is inserted and when any ancestor's `container` value
|
|
289
|
+
changes (a subtree walk from the changed node, the way `_redirectSubtree`
|
|
290
|
+
(`:2506`) drops `direction`). Containers are declared rarely and changed
|
|
291
|
+
more rarely, so the walk is not a hot path.
|
|
292
|
+
- Per dependent, the last boolean answer of each block, so that a re-sync runs
|
|
293
|
+
only when one flips (§1's caveat). `_sizeQueriesChanged` (`:2708`) is then
|
|
294
|
+
the re-sync as it stands.
|
|
295
|
+
|
|
296
|
+
`nodes.js`, on `WindowNode`, in `flush()`:
|
|
297
|
+
|
|
298
|
+
- `_resolveContainerQueries()` runs **after** the layout passes (`_layoutRoot`
|
|
299
|
+
or `_applyContentFloors`, `:11385-11392`) and **before**
|
|
300
|
+
`_absolutizeChildren(0, 0)` (`:11416`). For each dependent it reads its
|
|
301
|
+
containers' `yoga.getComputedWidth()`/`Height()` — yoga's computed values,
|
|
302
|
+
not `abs`, which is still the previous frame's — divides by the dependent's
|
|
303
|
+
scale, evaluates each block, and calls `_sizeQueriesChanged()` on the
|
|
304
|
+
dependents whose answer set changed. Their yoga nodes are dirty now; the
|
|
305
|
+
same style route a prop change takes marks their content floors stale
|
|
306
|
+
(`_floorsStale` / `_floorsDirty`, `:2350`), and one more layout step runs.
|
|
307
|
+
Then the §3 loop: re-read, pin what returned to an earlier answer, stop at
|
|
308
|
+
the cap. `_absolutizeChildren` runs once, against the final layout, so the
|
|
309
|
+
damage claims are right by construction.
|
|
310
|
+
- The same call goes inside `realize()`'s `measure()` loop (`:9480-9503`),
|
|
311
|
+
under the same cap, so an auto-sized window's natural size accounts for the
|
|
312
|
+
container blocks its content will resolve to.
|
|
313
|
+
- Windows are boundaries, as for every other walk (`!child.isWindow`): a
|
|
314
|
+
`<popup>` inside a container is a root of its own and asks its own window.
|
|
315
|
+
- `yoga.hasNewLayout()` is **not** the way to find containers that moved: it
|
|
316
|
+
is the scroller's witness, "consumed here and nowhere else" (`:6089`), and
|
|
317
|
+
reading it for 3,600 nodes costs seven times what reading the registered
|
|
318
|
+
containers does (§6). The container pass keeps its own last-size
|
|
319
|
+
bookkeeping and never calls `markLayoutSeen`.
|
|
320
|
+
|
|
321
|
+
One pre-existing quirk the new blocks would inherit, and the same PR should
|
|
322
|
+
fix: `flattenStyle` (`styles.js:663`) merges a **state** block that appears in
|
|
323
|
+
two entries of a style array but **replaces** a query block
|
|
324
|
+
(`isState(key) && into[key]`), so `[{ '@width >= 600': { a } }, { '@width >= 600': { b } }]`
|
|
325
|
+
keeps only `b`. Query keys should merge the same way.
|
|
326
|
+
|
|
327
|
+
## 6. Cost
|
|
328
|
+
|
|
329
|
+
The spike (§7) builds 3,613 yoga nodes — 12 sections of 25 cards of 11 leaves,
|
|
330
|
+
the shape of a card gallery — lays it out, then changes one layout property on
|
|
331
|
+
some of the cards and times the second pass. Medians of 7, in-process,
|
|
332
|
+
Apple M1 Pro:
|
|
333
|
+
|
|
334
|
+
| case | second pass | nodes yoga re-laid out |
|
|
335
|
+
| ---------------------------------------------- | ----------: | ---------------------: |
|
|
336
|
+
| nothing changed | 0.005 ms | 1 |
|
|
337
|
+
| 1 card flipped a block | 0.46 ms | 93 |
|
|
338
|
+
| 3 cards (1%) | 0.65 ms | 297 |
|
|
339
|
+
| 30 cards (10%) | 2.1 ms | 2,007 |
|
|
340
|
+
| all 300 cards | 3.0 ms | 3,613 |
|
|
341
|
+
| the window resized by 100 px (for scale) | 1.8 ms | 3,613 |
|
|
342
|
+
| **reading** width of 300 registered containers | 0.044 ms | |
|
|
343
|
+
| reading width + height of all 3,613 nodes | 0.82 ms | |
|
|
344
|
+
| `hasNewLayout()` over all 3,613 nodes | 0.33 ms | |
|
|
345
|
+
|
|
346
|
+
What this says: yoga's second pass is incremental — it re-lays out the dirtied
|
|
347
|
+
subtrees and reuses the cached layout of everything else — so the extra pass
|
|
348
|
+
costs in proportion to how much of the design actually changed, and even the
|
|
349
|
+
worst case (every card flips at once, which is a threshold crossing during a
|
|
350
|
+
resize, not a steady state) is under twice the resize pass it follows. The
|
|
351
|
+
counts are larger than the flipped subtrees alone because a card sits in a
|
|
352
|
+
wrapping row: a card that grows taller re-stretches every card on its line,
|
|
353
|
+
which is yoga doing exactly what the layout asks and what a real design pays
|
|
354
|
+
too.
|
|
355
|
+
Reading the containers is negligible **if** it reads the registered
|
|
356
|
+
containers directly and not a whole-tree flag walk.
|
|
357
|
+
|
|
358
|
+
What it does not measure, and where the real cost is:
|
|
359
|
+
|
|
360
|
+
- The JS half of a flip: `_syncStyle` on each dependent whose answer moved —
|
|
361
|
+
flatten, tokens, scale, `_retarget` — and the incremental content floors
|
|
362
|
+
for those nodes. This is the same per-node cost the window path pays today
|
|
363
|
+
for _every_ registered node on every resize; here it is paid per _flipped_
|
|
364
|
+
node, which is what the answer gating buys.
|
|
365
|
+
- The content floors are the expensive half of any relayout — three extra
|
|
366
|
+
passes were 21 of a 44 ms frame on 3,600 nodes before they were made
|
|
367
|
+
incremental (`_applyContentFloors`, `nodes.js:9257`). A container flip
|
|
368
|
+
re-measures only the flipped nodes' extents, but a flip that changes a
|
|
369
|
+
row's main-axis items does re-run the width pass for that row.
|
|
370
|
+
|
|
371
|
+
Frames where no block crosses a threshold — every scroll notch, every hover,
|
|
372
|
+
every resize frame between two breakpoints — pay the reads: tens of
|
|
373
|
+
microseconds at hundreds of containers.
|
|
374
|
+
|
|
375
|
+
## 7. The spike
|
|
376
|
+
|
|
377
|
+
Reproduced so the numbers can be re-run: save it at the repo root and run it
|
|
378
|
+
with `node`; it needs only `src/yoga.js`.
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
import { loadLayout, Yoga } from './src/yoga.js';
|
|
382
|
+
await loadLayout();
|
|
383
|
+
const all = [],
|
|
384
|
+
cards = [];
|
|
385
|
+
const node = (parent, i, setup) => {
|
|
386
|
+
const n = Yoga.Node.create();
|
|
387
|
+
setup?.(n);
|
|
388
|
+
parent?.insertChild(n, i);
|
|
389
|
+
all.push(n);
|
|
390
|
+
return n;
|
|
391
|
+
};
|
|
392
|
+
const root = node(null, 0, (n) => {
|
|
393
|
+
n.setWidth(1200);
|
|
394
|
+
n.setHeight(800);
|
|
395
|
+
});
|
|
396
|
+
for (let s = 0; s < 12; s++) {
|
|
397
|
+
const sec = node(root, s, (n) => {
|
|
398
|
+
n.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
|
|
399
|
+
n.setFlexWrap(Yoga.WRAP_WRAP);
|
|
400
|
+
n.setGap(Yoga.GUTTER_ALL, 8);
|
|
401
|
+
n.setPadding(Yoga.EDGE_ALL, 8);
|
|
402
|
+
});
|
|
403
|
+
for (let c = 0; c < 25; c++) {
|
|
404
|
+
const card = node(sec, c, (n) => {
|
|
405
|
+
n.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
|
|
406
|
+
n.setFlexWrap(Yoga.WRAP_WRAP);
|
|
407
|
+
n.setGap(Yoga.GUTTER_ALL, 4);
|
|
408
|
+
n.setPadding(Yoga.EDGE_ALL, 6);
|
|
409
|
+
n.setFlexGrow(1);
|
|
410
|
+
n.setFlexBasis(200);
|
|
411
|
+
n.setMinWidth(0);
|
|
412
|
+
});
|
|
413
|
+
cards.push(card);
|
|
414
|
+
for (let l = 0; l < 11; l++)
|
|
415
|
+
node(card, l, (n) => {
|
|
416
|
+
n.setWidth(40);
|
|
417
|
+
n.setHeight(20);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
let width = 1200;
|
|
422
|
+
const layout = () => root.calculateLayout(width, 800, Yoga.DIRECTION_LTR);
|
|
423
|
+
const time = (fn) => {
|
|
424
|
+
const t = performance.now();
|
|
425
|
+
fn();
|
|
426
|
+
return performance.now() - t;
|
|
427
|
+
};
|
|
428
|
+
const median = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1];
|
|
429
|
+
const trial = (name, prep) => {
|
|
430
|
+
const xs = [];
|
|
431
|
+
let touched;
|
|
432
|
+
for (let i = 0; i < 7; i++) {
|
|
433
|
+
prep(i);
|
|
434
|
+
all.forEach((n) => n.markLayoutSeen());
|
|
435
|
+
xs.push(time(layout));
|
|
436
|
+
touched = all.filter((n) => n.hasNewLayout()).length;
|
|
437
|
+
}
|
|
438
|
+
console.log(name, median(xs).toFixed(3), 'ms', touched, 'nodes re-laid out');
|
|
439
|
+
};
|
|
440
|
+
const flip = (card, i) =>
|
|
441
|
+
card.setFlexDirection(
|
|
442
|
+
i & 1 ? Yoga.FLEX_DIRECTION_ROW : Yoga.FLEX_DIRECTION_COLUMN,
|
|
443
|
+
);
|
|
444
|
+
layout();
|
|
445
|
+
trial('nothing changed', () => {});
|
|
446
|
+
trial('1 card', (i) => flip(cards[7], i));
|
|
447
|
+
trial('3 cards', (i) => {
|
|
448
|
+
for (let k = 0; k < 300; k += 100) flip(cards[k], i);
|
|
449
|
+
});
|
|
450
|
+
trial('30 cards', (i) => {
|
|
451
|
+
for (let k = 0; k < 300; k += 10) flip(cards[k], i);
|
|
452
|
+
});
|
|
453
|
+
trial('all cards', (i) => cards.forEach((c) => flip(c, i)));
|
|
454
|
+
trial('window resized', (i) => {
|
|
455
|
+
width = i & 1 ? 1100 : 1200;
|
|
456
|
+
root.setWidth(width);
|
|
457
|
+
});
|
|
458
|
+
const reads = (name, fn) =>
|
|
459
|
+
console.log(
|
|
460
|
+
name,
|
|
461
|
+
median(Array.from({ length: 7 }, () => time(fn))).toFixed(3),
|
|
462
|
+
'ms',
|
|
463
|
+
);
|
|
464
|
+
reads('read 300 containers', () => cards.forEach((c) => c.getComputedWidth()));
|
|
465
|
+
reads('read all nodes w+h', () =>
|
|
466
|
+
all.forEach((n) => n.getComputedWidth() + n.getComputedHeight()),
|
|
467
|
+
);
|
|
468
|
+
reads(
|
|
469
|
+
'hasNewLayout over all',
|
|
470
|
+
() => all.filter((n) => n.hasNewLayout()).length,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// pass 1: queried 230 -> now 310 / pass 2: queried 310 -> now 230 / …
|
|
474
|
+
// the cycle (§3): a block that moves the axis it queries never settles
|
|
475
|
+
const c = cards[0];
|
|
476
|
+
c.setFlexBasis(200);
|
|
477
|
+
layout();
|
|
478
|
+
for (let i = 0; i < 4; i++) {
|
|
479
|
+
const w = c.getComputedWidth();
|
|
480
|
+
c.setFlexBasis(w < 250 ? 300 : 200);
|
|
481
|
+
layout();
|
|
482
|
+
console.log(`pass ${i + 1}: queried ${w} -> now ${c.getComputedWidth()}`);
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## 8. Decisions the implementing PR has to make, and the suggested answer
|
|
487
|
+
|
|
488
|
+
| question | suggested answer |
|
|
489
|
+
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
490
|
+
| spelling | `'@container width >= 400'`, `'@container sidebar width >= 400'`; declaration `container: true` or `container: 'sidebar'` in style. Same operators and unit as `'@width'`. |
|
|
491
|
+
| unnamed query, no container above | development-time error naming the node, pointing at `container` and at `'@width'` for the window. |
|
|
492
|
+
| named query, no such container above | the block does not apply. Component reuse across contexts wins over catching a typo. |
|
|
493
|
+
| a block that never settles | pinned at the answer it first held this frame, one development warning naming container, dependent and axis; re-evaluated when the container moves for another reason (§3). |
|
|
494
|
+
| how many extra passes | a fixed small cap (3); "oscillation" is a dependent returning to an answer it already held this frame, so a nested cascade is not mistaken for one. |
|
|
495
|
+
| scale | thresholds divided by the dependent's `scale`. |
|
|
496
|
+
| window boundaries | a window/popup never depends on a container in another window. |
|
|
497
|
+
| `display: 'none'` container | nothing inside it is laid out or shown; nothing to decide. |
|
|
498
|
+
| transitions | a flipped block's paint properties transition through `_retarget` exactly as a window query's do today. |
|
|
499
|
+
| nested state block inside a container block | already allowed inside a window block (`validateStyle` recurses); same behaviour. |
|
|
500
|
+
| types | a `ContainerQuery` template-literal type beside `SizeQuery` (`style.d.ts:362`); `container` typed as `true` or a string in `StyleProperties`; one line in `test/types/api.tsx`. |
|
|
501
|
+
| docs | a section after "Window size queries" in styling.md; the "Next" list there drops its container-queries line; a scroller is named as the natural container. |
|
|
502
|
+
| the seam | `onLayout` on any `<box>` (generalizing `_reportViewport`'s trigger to `_assignAbs`), shipped first and on its own — useful with or without the query. |
|
|
503
|
+
|
|
504
|
+
Tests, in the shape of `test/size-queries.test.js`: a container crossing a
|
|
505
|
+
threshold under a window resize changes a dependent's style **and** its `abs`;
|
|
506
|
+
a `_layoutPasses` count that grows by one only on the frame an answer flips;
|
|
507
|
+
a wrapper `<box>` inserted between container and dependent changing nothing;
|
|
508
|
+
a named query reaching past a nearer unnamed container; an unnamed query with
|
|
509
|
+
no container throwing; a flattened array merging two blocks under the same
|
|
510
|
+
query key; and the oscillating design warning once and holding still across
|
|
511
|
+
frames.
|
|
512
|
+
|
|
513
|
+
## 9. Verdict
|
|
514
|
+
|
|
515
|
+
Container queries are feasible on this renderer at moderate size — on the
|
|
516
|
+
order of 60 lines in `styles.js`, 150 in `nodes.js`, the declaration and type
|
|
517
|
+
test, a doc section and a test file — and with no new primitive: the
|
|
518
|
+
after-layout resolve-and-relayout loop is the one `realize()` already runs,
|
|
519
|
+
made general and gated on answers actually flipping. The one thing CSS has
|
|
520
|
+
that yoga does not, containment, is replaced by exact runtime detection of
|
|
521
|
+
the only failure containment prevents, at a cost of one development warning
|
|
522
|
+
where CSS would have made the design unwritable. The targeting grammar should
|
|
523
|
+
be CSS's — nearest declared ancestor, optional name — for the reason CSS
|
|
524
|
+
arrived at it: it is the only rule under which a component's answer does not
|
|
525
|
+
depend on how its consumer chose to wrap it.
|