Skip to content

4227 shoelace to webawesome#4262

Open
shalgrim wants to merge 25 commits intobeeware:mainfrom
shalgrim:4227-shoelace-to-webawesome
Open

4227 shoelace to webawesome#4262
shalgrim wants to merge 25 commits intobeeware:mainfrom
shalgrim:4227-shoelace-to-webawesome

Conversation

@shalgrim
Copy link
Copy Markdown
Contributor

@shalgrim shalgrim commented Mar 21, 2026

Changes

Component Migration

Migrates the web backend from Shoelace (now in maintenance mode) to WebAwesome, its official successor maintained by the Font Awesome team.

There is no official migration guide from Shoelace to WebAwesome. Changes were verified against individual WebAwesome component docs:

Change Shoelace WebAwesome Source
Component prefix sl-* wa-* Rebranding — all WebAwesome components use wa-
Slider renamed sl-range wa-slider wa-slider docs
Menu removed sl-menu + sl-menu-item wa-dropdown-item directly wa-dropdown docs
Button variant variant="primary" variant="brand" wa-button docs
Events unprefixed sl-change, sl-input change, input wa-input, wa-select, wa-switch
Dialog API .show() / .hide() .open property wa-dialog docs
Divider direction vertical attribute orientation="vertical" wa-divider docs

Uses WebAwesome's shoelace.css compatibility theme to preserve existing CSS variable names and keep this PR incremental. A follow-up PR can migrate to native WebAwesome theming if desired.

Async element upgrade workarounds

WebAwesome registers custom elements asynchronously via a lazy loader. Unlike Shoelace's synchronous bundle, element properties (.value, .checked, .min, .max, etc.) may not exist when Python code runs during widget init. This causes AttributeError crashes on Switch, Selection, and Slider.

shoelace.js was a pretty sizeable file that loaded/defined every custom element in the shoelace library. WA doesn't do that. Instead it loads a very small autoloader that watches for mutations in the DOM and, if it sees a new custom element in the DOM that is in its library, then it downloads that code and defines the element and upgrades it to an element of the custom element class

However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. This causes AttributeErrors in both sets and gets.

The workaround is to wrap the native element in a proxy that:

  • For sets, maintains a dict (ordered by default in Python) that maintains property values in a dict before an element is upgraded.
  • For gets, refers to the dict before upgrade; if the element is not in the dict, delegates to the underlying element (which will itself raise AttributeError for custom-element-only properties pre-upgrade, letting the widget supply a default).
  • Registers a callback that, when components are defined, upgrades the element and "replays" the dict of attributes that got set.

Since we're wrapping the native element in a Python object, we provide an unwrap method that returns the native element for cases where it's necessary (i.e., when we call a JS method (e.g., appendChild, insertBefore)).

We can auto-unwrap any NativeProxys sent as arguments when the receiver is itself a NativeProxy by modifying our __getattr__ override to inspect any callables and arguments and unwrap where necessary. One .unwrap is still necessary when calling appendChild on Window's native since Window is not itself a NativeProxy.

Cleanup

  • Removed style_framework from example pyproject.toml files. This key was deprecated in Briefcase and is no longer consumed.
  • Changed Shoelace references to WebAwesome throughout documentation

Testing

Tested 19 web-compatible examples against both main (Shoelace) and this branch (WebAwesome) using manual interactive testing. All work the same on both branches.

What problem does this solve?

Shoelace is in maintenance mode and will not receive new features. WebAwesome is its actively developed successor.

Fixes #4227

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

Use the WebAwesome shoelace.css compatibility theme to preserve existing CSS variable names and minimize the scope of this migration.
- Rename sl-* tags to wa-* (e.g., sl-button -> wa-button)
- Rename sl-range to wa-slider (component was redesigned and renamed)
- Unprefix custom events: sl-change -> change, sl-input -> input
- Replace divider vertical attribute with orientation="vertical"
- Dialog: replace .show()/.hide() with .open property
  (https://www.webawesome.com/docs/components/dialog)
- Menu: remove sl-menu wrapper; wa-dropdown now takes
  wa-dropdown-item children directly
  (https://www.webawesome.com/docs/components/dropdown)
- Button variant: primary -> brand
  (https://www.webawesome.com/docs/components/button)
@freakboy3742
Copy link
Copy Markdown
Member

As you've noted, the style_framework key is no longer used by Briefcase. This was part of some work done last year in Briefcase; we retained the settings in the example apps as a transition mechanism - see #3822 for details on what needs to be done to clean up that transition mechanism.

#3891 already exists, ready to land, as a fix for the naming of the CSS file; it would make sense for this PR to clean up the rest of the style_framework handling.

Briefcase no longer uses the style_framework key; the web backend
now loads WebAwesome via insert files instead. Refs beeware#3822.
Comment thread examples/tutorial0/sandbox.html Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is actually a stray - it shouldn't exist in the repo at all. It's evidently some cruft that got accidentally committed when we first added Shoelace support.

The dist/ build requires a bundler to resolve bare module specifiers;
dist-cdn/ bundles all dependencies for direct browser use.
WebAwesome registers custom elements asynchronously, so native element
properties (.value, .checked, .min, .max, etc.) may not exist when
Python code runs during widget init. Add _get_native_attr/_set_native_attr
helpers on the base Widget class that catch AttributeError and warn
instead of crashing. Apply to Switch, Selection, and Slider.
Comment thread web/src/toga_web/widgets/base.py Outdated
@abstractmethod
def create(self): ...

def _get_native_attr(self, attr, default):
Copy link
Copy Markdown
Contributor Author

@shalgrim shalgrim Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the main change I made when things went broke. It seems WebAwesome does things async where shoelace did them sync so Toga was trying to update attributes that didn't exist yet.

There may be a better way to do this, but this at least gets the examples working as well as they do on main

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually, this makes sense; however, I think I'd like to have a better idea of why this is happening. "async" is a good starting point; but what set of conditions are leading to an attribute not existing? Looking at the places where this is being used, it looks like it could be a case of the widget not being fully initialised - in which case, that suggests there's a deeper workflow issue at work we need to defer some aspects of widget creation until we know the widget is fully initialised.

My main concern is the setter - if the setter is trying to set an initial value, and it's failing because the widget doesn't exist yet, then the fallback behavior means that initial value won't actually be set on the widget.

Comment thread web/src/toga_web/widgets/base.py Outdated
return getattr(self.native, attr)
except AttributeError:
tag = getattr(self.native, "tagName", "unknown")
warnings.warn(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of this verbosity exists because I was trained by a past security team and now I have a reflexive need to not have blank except blocks without at least logging. It could probably just be pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.

Comment thread web/src/toga_web/static/toga.css
@shalgrim shalgrim marked this pull request as ready for review March 27, 2026 02:40
@shalgrim
Copy link
Copy Markdown
Contributor Author

Status

@freakboy3742 FYI I'm putting this PR down for two weeks. But it's probably ready for a preliminary review to see if things are on the right track

I've made an effort to write the description to be clear about what I've done as far as changes and testing. I've also commented on various lines that I felt would benefit the reviewer in understanding certain changes.

As mentioned, when testing the different examples, I now find the 19 examples mostly work the same on this branch as on main. In some cases, they crash on both branches. In other cases, they're slightly broken in the same way.

The main difference is the styling is just...different. I haven't yet figured out how to get them to match better, but I'm not sure how important it is. Here is an example of one of the most striking differences. This is font_size. I think it may actually work better now.

image

The main difference that exists just about everywhere is that black/gray vs. blue. That's the part I haven't figured out how to fix yet and can't judge the severity of that difference.

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like good progress!

I've flagged my concerns with the async attribute getter/setter tooling; your explanation makes sense, but as noted inline, I'd like to have a better understanding of the why - and in particular, if there's anything we can do to avoid the problem.

The change in color is an odd one. The differences are pretty stark - I don't think it matters too much if there are some cosmetic differences, but the broad look and feel should be retained to the extent possible.

Comment thread web/src/toga_web/widgets/base.py Outdated
@abstractmethod
def create(self): ...

def _get_native_attr(self, attr, default):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually, this makes sense; however, I think I'd like to have a better idea of why this is happening. "async" is a good starting point; but what set of conditions are leading to an attribute not existing? Looking at the places where this is being used, it looks like it could be a case of the widget not being fully initialised - in which case, that suggests there's a deeper workflow issue at work we need to defer some aspects of widget creation until we know the widget is fully initialised.

My main concern is the setter - if the setter is trying to set an initial value, and it's failing because the widget doesn't exist yet, then the fallback behavior means that initial value won't actually be set on the widget.

Comment thread web/src/toga_web/widgets/base.py Outdated
return getattr(self.native, attr)
except AttributeError:
tag = getattr(self.native, "tagName", "unknown")
warnings.warn(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.

@freakboy3742
Copy link
Copy Markdown
Member

The test failures don't appear to be your problem - GitHub has clearly changed something in their Linux configuration over the last day or so.

@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like my linter got this though it's not related to the change of this PR. I'll change that back if it's not common practice.

@shalgrim
Copy link
Copy Markdown
Contributor Author

I'd like to have a better understanding of the why

So shoelace.js was a pretty sizeable file that loaded/defined every custom element in the shoelace library. WA doesn't do that. Instead it loads a very small autoloader that watches for mutations in the DOM and, if it sees a new custom element in its library, then it downloads that code and defines the element and upgrades it to an element of the custom element class

However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. Hence the AttributeErrors.

and in particular, if there's anything we can do to avoid the problem.

I think so. I'll start working on a pattern that:

  1. stores these special attributes in a dict
  2. waits on customElements.whenDefined to resolve when the tag has been registered by the autoloader
  3. forces the element to upgrade and "replays" that special attributes dict into actual attribute assignments

LMK if you have any thoughts on that approach

@freakboy3742
Copy link
Copy Markdown
Member

I'd like to have a better understanding of the why
...
However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. Hence the AttributeErrors.

Ok - that makes sense (or, at the very least, explains the behavior we're seeing here :-) ) Thanks for that investigation.

and in particular, if there's anything we can do to avoid the problem.

I think so. I'll start working on a pattern that:

  1. stores these special attributes in a dict
  2. waits on customElements.whenDefined to resolve when the tag has been registered by the autoloader
  3. forces the element to upgrade and "replays" that special attributes dict into actual attribute assignments

LMK if you have any thoughts on that approach

I think the general approach is on the right track.

We do a broadly similar thing for WebView on a couple of platforms (Windows, for example) - we can't set the URL on a webview until the webview is fully initialized, so we have a @requires_initialization decorator that checks if the view is initialised; if it isn't, the invocation is deferred until initialization is complete.

That works well for setter methods; getters need a little more handling to ensure that appropriate defaults are returned (or the getter has a way to get the "current" value if the element isn't initialized).

The other approach that might work is a proxy wrapper for the native object - If we write a class that intercepts __getattr__ and __setattr__, we can set up the native object so that attribute access is deferred until the autoloader completion signal fires, then replays those attribute calls.

I don't have any strong opinions on which of those approaches we should use, though.

The last approach,  _get_native_attr / _set_native_attr, handled WebAwesome's async upgrade by silently dropping pre-upgrade writes, so widgets lost any state configured before upgrade.
Replace with a NativeProxy that buffers pre-upgrade sets and replays them from a customElements.whenDefined callback after force-upgrading the element. Widget code reverts to direct self.native.foo access with local AttributeError fallbacks where reads can happen before writes.
Callers that pass .native into appendChild/insertBefore now use .unwrap() to reach the underlying JsProxy, since Pyodide does not auto-unwrap Python wrappers.
For example, you don’t want to set a slider’s value before its min and max
Comment thread examples/activityindicator/pyproject.toml
Comment thread examples/tutorial0/sandbox.html Outdated
from toga_web.libs import create_element, create_proxy


class NativeProxy:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class and its use below in this file are the main change (beyond mechanical changes) for this PR. I've tried to explain it in detail the code and comments themselves and the Async element upgrade workarounds section in the PR description

Comment thread web/src/toga_web/widgets/box.py Outdated

def add_child(self, child):
self.native.appendChild(child.native)
self.native.appendChild(child.native.unwrap())
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't send the Python object in to these JS calls, so this unwrap method sends in the JsProxy

return int(self.native.value)
try:
value = self.native.value
except AttributeError:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When getting a property that may not exist on a non-upgraded element and may not even be set in a "pending" state, we need to catch AttributeErrors so that we can supply a reasonable default

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense as a general pattern; we need to make sure we look to the interface for "reasonable defaults" when they might be available - in this case, "" seems a reasonable default.

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken a quick review pass; a couple of notes about possible cleanups and edge cases of the "no default yet" handling, but otherwise, this definitely looks like it's on the right track.


js.customElements.whenDefined(tag).then(create_proxy(on_defined))
else:
object.__setattr__(self, "_upgraded", True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To confirm my understanding - this is to allow wrapping non-WA elements? i.e., we could wrap a bare <button> element? Is there a use case for this, or is it purely for internal consistency?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal consistency but not just for consistency's sake. _create_native_widget wraps every native element in this NativeProxy. By setting non-wa elements to upgraded, we made sure that __getattr__ and __setattr__ work fine for those elements as well...basically that it bypasses the "replay/pending" mechanisms we've set up for thsse wa- elements.

Which may raise the question: Why don't we wrap only wa- elements? In that case I'd say it's for consistency when we call unwrap(). We don't want to have to check the type everywhere we call into javascript, so it's nice to know that we've always got a NativeProxy and that .unwrap will give us the underlying JS element as a JsProxy

Comment on lines +47 to +55
def unwrap(self):
"""Return the underlying JsProxy element.

Pyodide unwraps JsProxy arguments automatically when marshaling into
JS, but it does not unwrap arbitrary Python objects — so callers
that pass a NativeProxy as an *argument* to a JS method (e.g.
appendChild, insertBefore) must call unwrap() to hand JS the real Node.
"""
return self._element
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see what you're doing here and why, but we might be able to clean this up even more.

If you invoke proxy.appendChild(x), that does a __getattr__ call to retrieve the appendChild callable underlying object. So - if the attribute retrieved by __getattr__ is a callable, then you could wrap that callable in a method that automatically unwraps any NativeProxy arguments - something like:

def wrap(fn):
    def _fn(*args):
        wrapped_args = [
            arg.unwrap() if isinstance(arg, NativeProxy) else arg                    
            for arg in args
        ]
        return fn(*wrapped_args)
    return _fn

attr = getattr(self._element, name)
if callable(attr):
    attr = wrap(attr)
return attr

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're smart about it, we might even be able to auto-wrap callable arguments with create_proxy, removing another wart in the JS wrapping API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might be able to clean this up even more

That is cleaner for the user/caller. My question for you is: Is that cleanliness worth the extra complexity / cognitive overhead of future readers of the code trying to figure out what's happening and why? (It's a sincere question; have no strong opinion on the matter.)

And if the answer to that question is "yes" then my second question is: Do we want to do that as part of this PR or write up a detailed issue for somebody to tackle in the future? Here my (selfish?) take would be that this PR should be about getting off of shoelace and onto WebAwesome and put the cleanup in a future PR. But the question is still sincere.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My immediate impression is that the complexity is worth it, on the basis that it's going to be easy to forget when you need to use unwrap() (just as it's easy to forget that create_proxy() is needed). Yes, there's a cognitive overhead when things go wrong; but the counterpoint is that a developer who doesn't know the internals needs to understand what unwrap() does, and why it's needed, and those errors are going to be just as confusing; and in the case of create_proxy, there are cases where it will work without the create_proxy call, so you end up introducing subtle bugs that don't reveal themselves until later because you forgot to wrap a handler.

As for whether we do this later - I guess we could do this later to keep focus on this PR to "just" WebAwesome. My concern would be the amount of churn that happens adding (and then removing) unwrap() calls everywhere, but I guess that's a relatively simple search-and-replace. If you want to defer that part (or defer just the create_proxy part, then I'm OK with that.

However, I'd encourage you to have a quick poke around to see how much actual extra complexity there is - because I suspect the process of finding all the places where unwrap() is needed will be just as complex (if not more so) as adding global handling for unwrapping.

return int(self.native.value)
try:
value = self.native.value
except AttributeError:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense as a general pattern; we need to make sure we look to the interface for "reasonable defaults" when they might be available - in this case, "" seems a reasonable default.

try:
return float(self.native.value)
except AttributeError:
return 0.0
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My immediate reaction is that the interface should be able to provide a better default value for this and other slider properties... I might be wrong on this, though. It will depend a bit on specific ordering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, ordering is a challenge with max/min/step/value on sliders. The only other reasonable default I can think of is 0.5 since it's halfway between the default for min and max. Open to suggestions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a case where changing something in the base class is the actual solution - i.e., ensure that the interface has the "user requested" value available prior to creating the implementation widget.

However, that's also something that would be a lot more complex, and should be revealed by automated testing (when we eventually get that working for the web backend). So - in the interests of scope reduction, I'm ok with this being a "use a reasonable value" for now, and we can investigate further when we get automated testing that reveals a problem.

toga.css referenced --sl-* tokens that no longer exist in WA's
shoelace theme, leaving the header unstyled. Replace with --wa-*
equivalents. wa-button defaults to appearance="accent" which
rendered as a dark filled pill; switch to "outlined" to match the
previous Shoelace look and let per-widget background colors show
through.
Instead of needing to call .unwrap when we’re calling into a JS method, this adds to our __getattr__ override to, if the method we’re calling is a JS method, automatically unwrap any NativeProxy arguments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shoelace Project is now in Maintenance Mode

3 participants