Fixing a GNOME Shell St.ScrollView 1px layout collapse under unconstrained sizing queries
Tested Environment:
- OS: Ubuntu 22.04.5 LTS (Linux x86_64)
- Runtime: GNOME Shell 42.9 (GJS 1.72.4), Mutter 42.0+
- Session: Wayland & X11
Imagine this scenario: you are developing an extension or desktop UI component for GNOME Shell using JavaScript (GJS). The extension loads cleanly with zero errors in journalctl. All GObjects are instantiated, icons are built, but on the screen… absolute void.
When you log the actor geometry to the console, you see an unusual output:
appsScroll=1
appsBox=1
status=4294967040
Enter fullscreen mode Exit fullscreen mode
The parent St.ScrollView container has collapsed to a height of 1px, turning your interface into an invisible line across the screen. Meanwhile, its child grid actor claims its natural height is… 4,294,967,296 pixels.
In this article, we will walk through diagnosing this Clutter Scene Graph failure, examine the inner grid calculation code, and demonstrate two resolution levels with independent log traces.
1. Diagnostics & GJS ➔ C Marshaling Boundary
To trace how an unconstrained query (forWidth = -1) disrupts height calculation, we inspect ChildGridActor‘s geometry logic:
_calculateGridHeight(forWidth) {
let itemWidth = 64;
let numItems = 4; // 4 items in grid
// Unconstrained query: when forWidth = -1:
// Math.floor(-1 / 64) yields cols = -1
let cols = Math.floor(forWidth / itemWidth);
// Without a defensive check for forWidth <= 0, negative column counts
// result in negative row counts: Math.ceil(4 / -1) => -4 rows, returning -320px
let rows = Math.ceil(numItems / cols);
return rows * 80; // returns -320
}
vfunc_get_preferred_height(forWidth) {
let natH = this._calculateGridHeight(forWidth);
console.log(`[GJS] ChildGridActor._calculateGridHeight(${forWidth}) => ${natH}px`);
return [0, natH];
}
Enter fullscreen mode Exit fullscreen mode
Console log trace prior to applying the fix:
[GJS] ChildGridActor._calculateGridHeight(-1) => -320px
[Clutter C Pass] clutter_actor_get_preferred_height() => minH=0, natH=4294967296
StBoxLayout (parent): minH=0, natH=4294967296, allocH=600
StScrollView: minH=1, natH=4294967296, allocH=1 <-- Collapsed!
ChildGridActor: minH=0, natH=4294967296, allocH=0
Enter fullscreen mode Exit fullscreen mode
The GJS ➔ GObject/C Boundary Mechanism:
-
Inside JavaScript (GJS): Without a guard for
forWidth <= 0,_calculateGridHeight(-1)calculates a negative height-320px. -
At the GObject Introspection Marshaling Boundary: Returning a negative integer from GJS
vfunc_get_preferred_heightinto Clutter’s C structures casts the negative value to an unsignedguinttype in the C layer, converting-320to an unsanitizednatH = 4294967296. -
Parent
St.ScrollView: Receiving $4,294,967,296\text{ px}$, the container fails allocation and collapses toallocH = 1px.
2. Clutter API Contract: for_width = -1 is a Valid Query, Not a Bug
Passing for_width = -1 by Clutter engine is a documented API contract representing an unconstrained preferred size query.
-
ClutterBoxLayout: Under non-expanding alignment (alignwithexpand: false), layout managers query height without width constraints (for_width = -1). -
ChildGridActor: Lacks defensive checks forfor_width <= 0, causing division to evaluate tocols = -1and returning a negative height-320px.
3. Two Resolution Levels & Independent Empirical Traces
Solution 1: Defensive Component Fix (Recommended)
The robust engineering fix dynamically resolves width when forWidth <= 0 using parent allocation width or intrinsic content bounds (_getPreferredGridWidth):
vfunc_get_preferred_height(forWidth) {
// Dynamic defensive width fallback under unconstrained queries (forWidth <= 0)
let fallbackWidth = this.get_parent() ? this.get_parent().get_width() : this._getPreferredGridWidth();
let effectiveWidth = forWidth > 0 ? forWidth : fallbackWidth;
let natH = this._calculateGridHeight(effectiveWidth);
return [0, Math.max(0, natH)];
}
Enter fullscreen mode Exit fullscreen mode
Empirical Log Trace for Solution 1 (Defensive Fix under forWidth = -1):
[GJS] ChildGridActor._calculateGridHeight(280) => 340px
[Clutter C Pass] clutter_actor_get_preferred_height() => minH=0, natH=340 <-- Defensive fallback handles -1 gracefully!
StBoxLayout (parent): minH=0, natH=340, allocH=600
StScrollView: minH=1, natH=340, allocH=600 <-- Resolved!
ChildGridActor: minH=0, natH=340, allocH=340
Enter fullscreen mode Exit fullscreen mode
Solution 2: Layout Configuration Workaround
If editing the custom widget code is restricted, passing explicit expansion flags at the container layout level (x_expand: true, y_expand: true):
- const grid = new St.Widget({
- x_align: Clutter.ActorAlign.CENTER,
- y_align: Clutter.ActorAlign.FILL
- });
- grid.queue_relayout();
+ const grid = new St.Widget({
+ style_class: 'app-grid',
+ x_expand: true,
+ y_expand: true,
+ });
Enter fullscreen mode Exit fullscreen mode
Empirical Log Trace for Solution 2 (Layout Workaround):
[Clutter C Pass] ChildGridActor.vfunc_get_preferred_height(forWidth=280) => minH=0, natH=340 <-- Layout passes container width!
StBoxLayout (parent): minH=0, natH=340, allocH=600
StScrollView: minH=1, natH=340, allocH=600 <-- Resolved!
ChildGridActor: minH=0, natH=340, allocH=340
Enter fullscreen mode Exit fullscreen mode
4. Disproven Workarounds (Failed Approaches)
Before finding the correct fix, 3 common workarounds were tested, each creating regressions:
❌ Failed Approach #1: Hardcoding min-height in CSS
.my-scroll-view {
min-height: 300px;
}
Enter fullscreen mode Exit fullscreen mode
-
Why it failed: The 1px collapse stopped, but
ScrollViewlost responsiveness: it stopped adapting dynamically to screen resolution changes and clipped overflowing content.
❌ Failed Approach #2: Invoking actor.queue_relayout() in Constructor
_init() {
super._init();
// Attempting to force relayout during construction
this.queue_relayout();
}
Enter fullscreen mode Exit fullscreen mode
-
Why it failed: Calling
queue_relayout()inside_init()while GObject properties were partially initialized triggered a recursive layout cycle during startup. This resulted in an emptyAppFavoritesgrid race condition on boot.
❌ Failed Approach #3: Competing Geometry Owners
Setting actor geometry simultaneously from JavaScript (actor.set_width(...)) and CSS (stylesheet.css) created conflicting layout loops where JS and CSS layout managers continuously overwrote allocation boxes.
5. Verification
1. Repository Validation
Run bash verify.sh to check JS syntax across reproduction/, broken/, and fixed/, verify metadata structure, and execute tools/validate_cases.py:
bash cases/gnome-shell/st-boxlayout-invalid-natural-height/verify.sh
Enter fullscreen mode Exit fullscreen mode
2. Runtime Verification in GNOME Shell
- Install and enable the extension in a GNOME Shell 42.9 session (Ubuntu 22.04.5 LTS).
- Open the side panel containing
St.ScrollView. - Confirm
appsScrollallocation height is $> 100\text{px}$ and icons render cleanly without collapsing.
Conclusion
An St.ScrollView 1px collapse occurs when a custom child actor returns a negative height (-320px) under unconstrained queries (forWidth = -1), casting to an unsigned integer overflow $4,294,967,296$ at the GJS ➔ C marshaling boundary.
A complete resolution combines dynamic forWidth <= 0 fallback handling inside the custom widget with explicit x_expand: true / y_expand: true layout flags, dropping natural height down to 340px.
답글 남기기