Conversation
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)
|
As you've noted, the #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 |
Briefcase no longer uses the style_framework key; the web backend now loads WebAwesome via insert files instead. Refs beeware#3822.
There was a problem hiding this comment.
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.
| @abstractmethod | ||
| def create(self): ... | ||
|
|
||
| def _get_native_attr(self, attr, default): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| return getattr(self.native, attr) | ||
| except AttributeError: | ||
| tag = getattr(self.native, "tagName", "unknown") | ||
| warnings.warn( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.
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
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. |
freakboy3742
left a comment
There was a problem hiding this comment.
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.
| @abstractmethod | ||
| def create(self): ... | ||
|
|
||
| def _get_native_attr(self, attr, default): |
There was a problem hiding this comment.
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.
| return getattr(self.native, attr) | ||
| except AttributeError: | ||
| tag = getattr(self.native, "tagName", "unknown") | ||
| warnings.warn( |
There was a problem hiding this comment.
Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.
|
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 | |||
There was a problem hiding this comment.
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.
So However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla
I think so. I'll start working on a pattern that:
LMK if you have any thoughts on that approach |
Ok - that makes sense (or, at the very least, explains the behavior we're seeing here :-) ) Thanks for that investigation.
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 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 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
| from toga_web.libs import create_element, create_proxy | ||
|
|
||
|
|
||
| class NativeProxy: |
There was a problem hiding this comment.
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
|
|
||
| def add_child(self, child): | ||
| self.native.appendChild(child.native) | ||
| self.native.appendChild(child.native.unwrap()) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
freakboy3742
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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

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:
sl-*wa-*wa-sl-rangewa-slidersl-menu+sl-menu-itemwa-dropdown-itemdirectlyvariant="primary"variant="brand"sl-change,sl-inputchange,input.show()/.hide().openpropertyverticalattributeorientation="vertical"Uses WebAwesome's
shoelace.csscompatibility 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 causesAttributeErrorcrashes on Switch, Selection, and Slider.shoelace.jswas 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 classHowever, 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 causesAttributeErrorsin both sets and gets.The workaround is to wrap the native element in a proxy that:
Since we're wrapping the native element in a Python object, we provide an
unwrapmethod 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 aNativeProxyby modifying our__getattr__override to inspect any callables and arguments and unwrap where necessary. One.unwrapis still necessary when callingappendChildonWindow'snativesinceWindowis not itself aNativeProxy.Cleanup
style_frameworkfrom example pyproject.toml files. This key was deprecated in Briefcase and is no longer consumed.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: