Debugging a GNOME Shell layout collapse: how a 4,294,967,296 px preferred height collapsed St.ScrollView to 1 px

작성자

카테고리:

← 피드로
DEV Community · Fedor Pasynkov · 2026-08-07 개발(SW)

Fedor Pasynkov

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:

  1. Inside JavaScript (GJS): Without a guard for forWidth <= 0, _calculateGridHeight(-1) calculates a negative height -320px.
  2. At the GObject Introspection Marshaling Boundary: Returning a negative integer from GJS vfunc_get_preferred_height into Clutter’s C structures casts the negative value to an unsigned guint type in the C layer, converting -320 to an unsanitized natH = 4294967296.
  3. Parent St.ScrollView: Receiving $4,294,967,296\text{ px}$, the container fails allocation and collapses to allocH = 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 (align with expand: false), layout managers query height without width constraints (for_width = -1).
  • ChildGridActor: Lacks defensive checks for for_width <= 0, causing division to evaluate to cols = -1 and 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 ScrollView lost 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 empty AppFavorites grid 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

  1. Install and enable the extension in a GNOME Shell 42.9 session (Ubuntu 22.04.5 LTS).
  2. Open the side panel containing St.ScrollView.
  3. Confirm appsScroll allocation 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다