Image$isFilled() always returns FALSE

Everything related to the development of modules in jamovi
User avatar
NourEdinDarwish
Posts: 20
Joined: Fri Jan 23, 2026 9:14 pm

Image$isFilled() always returns FALSE

Post by NourEdinDarwish »

Hi everyone,

I want to use isFilled() on Image elements to know if an image is not going to be rendered this run. The goal is to skip some related calculations for it inside .run(), similar to the example mentioned in the API guide for tables:

Code: Select all

 table$isFilled() 
However, I am finding that self$results$myPlot$isFilled() is always FALSE inside .run() even when I change an option that is not in the image's "clearWith" list.

My question is: Is this expected behavior? We calculate some things related to the image in .run() and want to make it only happen when the image is actually cleared. Is isFilled() not intended to be used with Images to check if they have been preserved/cleared the same way we use it for tables?

Thanks!
User avatar
jonathon
Posts: 2973
Joined: Fri Jan 27, 2017 10:04 am

Re: Image$isFilled() always returns FALSE

Post by jonathon »

yeah, that's unexpected. if clearWith isn't triggered then the path to the image should be maintained. i.e. this shouldn't be NULL:

self$results$myPlot$.__enclos_env__$private$.filePath

if that path isn't null, then $isFilled() should return TRUE

i assume that clearWith means that the image isn't being cleared? but you're still finding $isFilled() returning FALSE?

jonathon
User avatar
NourEdinDarwish
Posts: 20
Joined: Fri Jan 23, 2026 9:14 pm

Re: Image$isFilled() always returns FALSE

Post by NourEdinDarwish »

Hi Jonathon,

Thanks for the quick reply. I traced the issue and it only affects images that use setSize() in .run() (dynamically sized images). Fixed-size images (width/height set in the YAML) work fine.

What happens

When clearWith does not trigger, fromProtoBuf in image.R still clears filePath because of the sizeChanged check. Every engine request creates a fresh R6 object where the image starts at the width/height defined in the YAML. But the stored protobuf has the dimensions from the last setSize() call in .run(). These never match, so filePath is always set to NULL, and isFilled() always returns FALSE.

We cannot call setSize() in .init() to match the stored dimensions because the correct size depends on objects computed in .run() (the model, the data, etc.).

Why sizeChanged is redundant here

If fromProtoBuf passed the clearWith checks, it means the data and options that drive the plot have not changed. The size set by setSize() was computed from those same inputs, so it should not have changed either. The mismatch only exists because the fresh object starts at the YAML dimensions and has not had setSize() called yet.

From a module builder's perspective there is no case where setSize() would produce a different result without the inputs having changed, and those inputs would be in clearWith:

- If the size depends on data (e.g., number of studies changes the plot height), the data variable is already in clearWith, so clearWith triggers and the image is cleared.
- If the size depends on an option (e.g., layout mode), that option is already in clearWith too.
- If the size is truly fixed, the developer would use width/height in the YAML and not call setSize() at all.

The only case where size can change independently of clearWith is when the user drag-resizes the image, which changes the widthScale/heightScale options. That already shows up in oChanges and can be detected separately.

The effect

Because of this, any image that uses dynamic setSize() will re-render on every option change even when nothing relevant changed if I used it outside .init. Module developers also cannot use isFilled() to skip expensive computations for these images.

Suggestion

Remove sizeChanged from the filePath clearing condition in fromProtoBuf. The user drag-resize case is already covered by the scale options in oChanges. When clearWith did not fire and there is no scale change, the stored filePath and dimensions should be preserved from the protobuf instead of being cleared.

Thanks!
Nour
User avatar
NourEdinDarwish
Posts: 20
Joined: Fri Jan 23, 2026 9:14 pm

Re: Image$isFilled() always returns FALSE

Post by NourEdinDarwish »

Hi Jonathon,

Sorry, I think I complicated my first reply. Let me simplify.

You may remember the GitHub issue where I asked about dynamically sizing images. You suggested the .postInit + .run pattern:

- In .run(), calculate the size, call setSize(), and cache the dimensions in a hidden element.
- In .postInit(), read the cached dimensions and apply them with setSize().

I implemented that, and I also wanted to use isFilled() in .run() to skip the expensive dimension calculation when the plot has not been cleared.

Simplified r.yaml example:

Code: Select all

items:
    - name: plot
      type: Image
      width: 500
      height: 400
      renderFun: .plotForest
      requiresData: true
      clearWith:
          - x
          - plotColor
          - ...

    - name: sizeCache
      type: Group
      visible: false
      clearWith: []
The R code:

Code: Select all

.postInit = function() {
    size <- self$results$sizeCache$state
    if (!is.null(size) && self$results$plot$visible)
        self$results$plot$setSize(size$w, size$h)
}

.run = function() {
    if (!self$results$plot$isFilled() && self$results$plot$visible && !is.null(self$model)) {
        size <- calculateDimensions(self$model)
        self$results$plot$setSize(size$w, size$h)
        self$results$sizeCache$setState(list(w = size$w, h = size$h))
    }
}
Two problems:

1. isFilled() always returns FALSE after the first render, so I can never skip the dimension calculation in .run().
2. The image re-renders on every option change, even when clearWith did not trigger.

If I remove the setSize() call from .run() and use fixed dimensions in the YAML instead, isFilled() works correctly and the image only re-renders when clearWith fires.

So the issue is specifically with setSize(). When .run() calls setSize(), the stored dimensions change (e.g., to 800x600). On the next request, the fresh object starts at the YAML dimensions (e.g., 500x400). The sizeChanged check in fromProtoBuf sees the mismatch and clears filePath, which makes isFilled() return FALSE and forces a re-render, even though clearWith did not fire and the data has not changed.

I think a size change on its own should not invalidate the image. If the size is dynamic, it is always driven by data or options. Those inputs should be in clearWith. If clearWith did not fire, the inputs have not changed, so the size has not actually changed either. The mismatch only exists because the fresh R6 object has not had setSize() applied yet, not because anything meaningful changed.

I cannot avoid calling setSize() in .run() because the correct dimensions depend on the computed model, which is only available in .run().

Thanks!
Nour
User avatar
jonathon
Posts: 2973
Joined: Fri Jan 27, 2017 10:04 am

Re: Image$isFilled() always returns FALSE

Post by jonathon »

hey,

sorry, i'm on the road at the moment, so haven't been able to look into this properly. but yeah, i see the problem.

this does seem like a use-case we've not encountered before, and i have to think carefully about how to enable it.

maybe setSize() shouldn't invalidate the image as you say ... that doesn't feel right, but might be a solution with minimal side-effects.

jonathon
User avatar
NourEdinDarwish
Posts: 20
Joined: Fri Jan 23, 2026 9:14 pm

Re: Image$isFilled() always returns FALSE

Post by NourEdinDarwish »

Hi Jonathon,

Thanks for looking into it.

I get why dropping the size check globally feels wrong. You wouldn't want to break the invalidation rules for standard images.

What do you think about an opt-in flag? Maybe adding a property like dynamicSize: true to the YAML for the Image element. If that is set, fromProtoBuf could skip the size check for that specific plot, keeping the safety net intact for everything else.

Whenever you decide on the best way to handle this, just let me know.

Thanks,
Nour
User avatar
jonathon
Posts: 2973
Joined: Fri Jan 27, 2017 10:04 am

Re: Image$isFilled() always returns FALSE

Post by jonathon »

ah, the problem with not invalidating from a size change is that the images won't be re-rendered if the user drags to resize an image ...

still thinking ...

jonathon
User avatar
jonathon
Posts: 2973
Joined: Fri Jan 27, 2017 10:04 am

Re: Image$isFilled() always returns FALSE

Post by jonathon »

can i ask what R package we're trying to accommodate here? can we not examine its source, and anticipate the image size that will be produced?

jonathon
User avatar
NourEdinDarwish
Posts: 20
Joined: Fri Jan 23, 2026 9:14 pm

Re: Image$isFilled() always returns FALSE

Post by NourEdinDarwish »

Hi Jonathon,

On the drag-resize concern:

I understand the concern. Looking at fromProtoBuf in image.R, the width and height in the protobuf are the full dimensions (already including whatever scale the client applied), and they are restored unconditionally:

Code: Select all

private$.width <- image$width
private$.height <- image$height
if (image$path == '' || 'theme' %in% oChanges || 'palette' %in% oChanges)
    private$.filePath <- NULL
else
    private$.filePath <- image$path
So yes, if you were to add a width/height comparison to decide whether to clear filePath, a user drag-resize would be caught by that comparison too, so you cannot simply remove a size check without losing drag-resize invalidation.

However, when the user drags to resize, jamovi updates the widthScale / heightScale options and those appear in oChanges. So the fix would be to invalidate filePath when widthScale or heightScale appear in oChanges, the same way theme and palette are already handled on that line, rather than comparing the raw width/height values. That way:

- User drag-resize --> widthScale/heightScale in oChanges --> filePath cleared --> image re-renders
- Programmatic setSize() from .run()/.postInit() --> no oChanges entry --> filePath preserved --> image does not re-render

The current problem is that on each engine request a fresh R6 object starts at the YAML dimensions, while the stored protobuf has the dimensions from the last setSize() call. Any width/height comparison will see a mismatch and clear filePath, even though nothing meaningful changed. The mismatch only exists because the fresh object has not had setSize() applied yet.

On the R package / anticipating image size:

We are using the meta R package for meta-analysis. The specific plot is a forest plot, but this is not unique to meta. It is a general characteristic of forest plots and similar composite graphics that embed tabular text alongside graphical elements.

Forest plots are fundamentally different from most R plots when it comes to sizing. A typical plot, like a ggplot scatter plot, uses relative coordinates; the axes, points, and labels all scale proportionally to fill whatever device size you give it. You can render it at 500x400 or 800x600 and it looks fine either way, just bigger or smaller.

Forest plots do not work like that. They render study labels, effect estimates, confidence intervals, and summary statistics as columns of text next to a graphical panel. These text columns use fixed, absolute units (inches/cm) because font metrics are fixed; a character rendered at 12pt takes a specific number of inches regardless of the device size. The columns do not stretch or shrink to fill the device. If you give the device too little width, the text gets clipped. If you give it too much, you get empty whitespace. The same applies to height; each study row takes a fixed amount of vertical space, so a 5-study forest plot and a 50-study forest plot need very different heights.

This means the total dimensions of the plot are entirely determined by the content: the number of studies, the length of the longest label, the number of columns, font size, spacing, etc. You cannot set a fixed width/height in the YAML and have it work for all inputs.

While meta::forest() does have some internal logic to estimate the height, it is a complex function that requires many parameters and in practice does not always produce the correct result. You end up with cropping or excessive whitespace. This is actually an open issue in the meta package where other users have the same problem of needing to guess dimensions for export.

The correct and reliable approach is to build the plot first, then measure the actual grid object to extract its true dimensions. This is what we do: we render the forest plot into a grid grob, then use grid::convertWidth() and grid::convertHeight() to read back the exact width and height in inches. Other forest plot packages like forestploter use the same technique.

Even if we tried to write a custom function to anticipate the dimensions mathematically, it would be extremely fragile. The final size depends on the exact string width of every label and number in the table. Trying to calculate all of that analytically before rendering is not just complex, it's error-prone. Measuring the final rendered grid object is the only robust way to guarantee the plot won't be cropped and won't have excessive padding.

So we cannot anticipate the image size from the source or options alone. We need to compute the model, render the plot, measure it, and then call setSize() with the measured dimensions. That is why setSize() must be called from .run(), and why the current invalidation creates the problem described above.

Thanks,
Nour
User avatar
jonathon
Posts: 2973
Joined: Fri Jan 27, 2017 10:04 am

Re: Image$isFilled() always returns FALSE

Post by jonathon »

> So the fix would be to invalidate filePath when widthScale or heightScale appear in oChanges

this does seem like an avenue we could pursue ... good work.

> we cannot anticipate the image size from the source or options alone

yeah i understand. is it possible to use a dummy model? i.e. one that contains all the labels, but is built using a bare minimum of data, and hence takes very little time to compute?

jonathon
Post Reply