FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(runtime,hooks)!: make rendering async-first with Suspense by owenthcarey · Pull Request #17 · pythonnative/pythonnative · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .md  (17) .py  (29) .sh  (1) .yaml  (12) .yml  (2) All 5 file types selected
Only manifest files
Deleted files Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
50 changes: 33 additions & 17 deletions .github/workflows/e2e.yml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ jobs:
# GitHub-hosted Android emulators grow unstable after ~15 minutes of
# sustained Maestro driving and start reporting "device offline" /
# "device not found" mid-run (the emulator VM, not the app, dies).
# The full 60-flow suite reliably crosses that threshold around the
# ~35th flow. Sharding the run into a few balanced groups, each on
# its own freshly booted emulator, keeps every session well under
# the limit, and parallelizes the Android pass as a bonus. Groups are
# sized so none exceeds ~15 flows; ``components`` (28 flows after the
# gesture/animated additions) crossed the ceiling at ~flow 26, so it
# runs as two halves. ``fail-fast: false`` so one shard's failure
# still lets the others report.
# The full suite reliably crosses that threshold around the ~35th
# flow. Sharding the run into a few balanced groups, each on its
# own freshly booted emulator, keeps every session well under the
# limit, and parallelizes the Android pass as a bonus. Groups are
# sized so none exceeds ~15 flows; ``components`` (28 flows after
# the gesture/animated additions) crossed the ceiling at ~flow 26,
# so it runs as two halves, and ``hooks`` (22 flows after the async
# hook additions) now takes ~10 minutes of driving on its own, so
# it gets a dedicated emulator. ``fail-fast: false`` so one shard's
# failure still lets the others report.
strategy:
fail-fast: false
matrix:
Expand All @@ -45,10 +47,12 @@ jobs:
suites: components-a
- name: components-b
suites: components-b
- name: hooks-nav-gestures
suites: hooks navigation gestures
- name: layout-styling-anim-misc
suites: layout styling animations misc
- name: hooks
suites: hooks
- name: nav-gestures-layout-styling
suites: navigation gestures layout styling
- name: anim-misc
suites: animations misc

name: e2e-android (${{ matrix.name }})

Expand Down Expand Up @@ -102,7 +106,18 @@ jobs:

e2e-ios:
needs: coverage
runs-on: macos-latest
# Pinned to macos-15 rather than macos-latest: the macos-26 image
# (default since mid-July 2026) sporadically delivers a single
# XCTest-synthesized tap as TWO UIControl action sends (~25% of
# taps; the app's unified log shows one UIEvent followed by a
# doubled "send control actions" burst ~1ms apart). It reproduces
# on both the iOS 26.5 and 26.2 simulator runtimes on that image,
# but never on a macOS 15 host, so the host-side simulator/XCTest
# event-injection stack is the trigger. Doubled taps break every
# non-idempotent press handler: reducer counters jump by two,
# alerts present twice, pickers reopen after selecting. This suite
# ran green on the macos-15 image for months.
runs-on: macos-15
# Budget for the worst case: ~8 min build/boot plus two full suite
# attempts (run-e2e.sh retries the whole suite once after a driver
# flake) at up to ~20 min each on a slow runner. 40 was enough for
Expand Down Expand Up @@ -142,13 +157,14 @@ jobs:
- name: Install PythonNative
run: pip install -e .

- name: Install Maestro and idb
# Maestro drives iOS simulators with its own bundled XCTest
# runner; idb-companion (once installed here) hasn't been needed
# since Maestro dropped idb, and its formula no longer installs
# on macos-15 anyway (no bottle, and building needs Xcode 26).
- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
# Homebrew refuses formulae from untrusted third-party taps by
# default; trust the tap explicitly before installing.
brew tap facebook/fb && brew trust facebook/fb && brew install idb-companion

- name: Build and run E2E tests
run: ./scripts/run-e2e.sh ios ${{ matrix.suites }}
Expand Down
1 change: 1 addition & 0 deletions README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
- **Rich component library:** 25+ built-in components backed by real native widgets: `TextInput`, `Image` / `ImageBackground`, `ScrollView`, `FlatList` / `SectionList`, `Modal`, `Pressable` / `TouchableOpacity`, `Switch` / `Checkbox`, `Slider`, `SegmentedControl`, `Picker`, `DatePicker`, `ProgressBar` / `ActivityIndicator`, `WebView`, and more.
- **Device APIs:** Cross-platform modules for `Camera`, `Location`, `FileSystem`, `Notifications`, `Clipboard`, `Share`, `Linking`, `Permissions`, `AppState`, `NetInfo`, `SecureStore`, `Battery`, `Haptics` / `Vibration`, and `Biometrics`, plus reactive `use_app_state` and `use_net_info` hooks.
- **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect` / `use_layout_effect`, refs and imperative handles with `use_ref` / `use_imperative_handle`, and navigation with `use_navigation`, all through one consistent pattern. Components can return a single element, a list of siblings, or `None`, and `Fragment`, `Portal`, and reactive `Provider` context work the way they do in React.
- **Async-first rendering:** One `asyncio` event loop runs the whole framework on the platform's main thread. Components can be `async def` and `await` data directly in the body; `Suspense` boundaries show declarative loading states while they wait. `use_resource` fetches during render, `use_effect` accepts coroutine callbacks (cancelled automatically on unmount), `lazy` code-splits components, and `use_transition` / `use_deferred_value` keep the UI responsive during expensive updates.
- **Developer feedback that finds your bugs:** In dev mode (`pn preview`, hot reload, or `PN_DEV=1`), uncaught errors from renders, effects, and event handlers show a full-screen RedBox with the traceback; unknown style keys and duplicate list keys print "did you mean" warnings; and conditional hooks raise a `HookOrderError` at the source instead of silently cross-wiring state.
- **Typed `style` prop:** Pass all visual and layout properties through a single `style` dict, fully described by the `pn.Style` `TypedDict` and the ergonomic `pn.style(...)` helper for IDE autocomplete and static checking. Compose reusable styles with `StyleSheet`.
- **Cross-platform flexbox engine:** A pure-Python, Yoga-style layout engine computes frames once and applies them to native views, so `flex`, `padding`, `aspect_ratio`, and `position: "absolute"` produce the same geometry on Android and iOS.
Expand Down
13 changes: 10 additions & 3 deletions docs/api/hooks.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ slot across renders.
For coroutines and data-driven UI, PythonNative ships dedicated
async-aware hooks layered on top of `use_state` / `use_effect`:

- [`use_async_effect`][pythonnative.use_async_effect]: async sibling
of `use_effect`; cancels the in-flight coroutine on re-run /
unmount.
- [`use_effect`][pythonnative.use_effect] accepts `async def`
callbacks directly; the coroutine runs as a task and is cancelled
on re-run / unmount.
- [`use_resource`][pythonnative.use_resource]: starts a fetch during
render and caches it; reading a pending
[`Resource`][pythonnative.Resource] suspends the render (pair with
[`Suspense`][pythonnative.Suspense]).
- [`use_transition`][pythonnative.use_transition] /
[`use_deferred_value`][pythonnative.use_deferred_value]: mark
expensive updates as low priority so urgent updates render first.
- [`use_query`][pythonnative.use_query]: subscribes to an async
fetcher and re-renders on data / error / refetch.
- [`use_mutation`][pythonnative.use_mutation]: wraps an async
Expand Down
3 changes: 2 additions & 1 deletion docs/api/pythonnative.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ The reference is split per module so each page stays scannable:
| Area | Page | Key symbols |
|---|---|---|
| Element factories | [Components](components.md) | [`Text`][pythonnative.Text], [`Button`][pythonnative.Button], [`Column`][pythonnative.Column], [`Row`][pythonnative.Row], [`ScrollView`][pythonnative.ScrollView], [`FlatList`][pythonnative.FlatList], [`SectionList`][pythonnative.SectionList], [`Modal`][pythonnative.Modal], [`Pressable`][pythonnative.Pressable], [`StatusBar`][pythonnative.StatusBar], [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView], [`RefreshControl`][pythonnative.RefreshControl], [`Picker`][pythonnative.Picker], [`Fragment`][pythonnative.Fragment], [`Portal`][pythonnative.Portal], [`ErrorBoundary`][pythonnative.ErrorBoundary] |
| Hooks | [Hooks](hooks.md) | [`use_state`][pythonnative.use_state], [`use_reducer`][pythonnative.use_reducer], [`use_effect`][pythonnative.use_effect], [`use_layout_effect`][pythonnative.use_layout_effect], [`use_memo`][pythonnative.use_memo], [`use_ref`][pythonnative.use_ref], [`use_imperative_handle`][pythonnative.use_imperative_handle], [`use_context`][pythonnative.use_context], [`use_back_handler`][pythonnative.use_back_handler], [`use_window_dimensions`][pythonnative.use_window_dimensions], [`use_safe_area_insets`][pythonnative.use_safe_area_insets], [`use_keyboard_height`][pythonnative.use_keyboard_height], [`use_color_scheme`][pythonnative.use_color_scheme], [`memo`][pythonnative.memo] |
| Hooks | [Hooks](hooks.md) | [`use_state`][pythonnative.use_state], [`use_reducer`][pythonnative.use_reducer], [`use_effect`][pythonnative.use_effect], [`use_layout_effect`][pythonnative.use_layout_effect], [`use_memo`][pythonnative.use_memo], [`use_ref`][pythonnative.use_ref], [`use_imperative_handle`][pythonnative.use_imperative_handle], [`use_context`][pythonnative.use_context], [`use_back_handler`][pythonnative.use_back_handler], [`use_window_dimensions`][pythonnative.use_window_dimensions], [`use_safe_area_insets`][pythonnative.use_safe_area_insets], [`use_keyboard_height`][pythonnative.use_keyboard_height], [`use_color_scheme`][pythonnative.use_color_scheme], [`use_resource`][pythonnative.use_resource], [`use_transition`][pythonnative.use_transition], [`use_deferred_value`][pythonnative.use_deferred_value], [`memo`][pythonnative.memo] |
| Suspense | [Suspense](suspense.md) | [`Suspense`][pythonnative.Suspense], [`Resource`][pythonnative.Resource], [`start_resource`][pythonnative.start_resource], [`lazy`][pythonnative.lazy] |
| Animations | [Animated](animated.md) | `Animated`, [`AnimatedValue`][pythonnative.AnimatedValue], [`use_animated_value`][pythonnative.use_animated_value] |
| System dialogs | [Alerts](alerts.md) | [`Alert`][pythonnative.Alert] |
| Platform | [Platform](platform.md) | [`Platform`][pythonnative.Platform] |
Expand Down
11 changes: 7 additions & 4 deletions docs/api/runtime.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# Async runtime

PythonNative runs a single framework-wide ``asyncio`` event loop on a
dedicated daemon thread. Every awaitable surface in the framework
schedules its work on this loop:
[`use_async_effect`][pythonnative.hooks.use_async_effect],
PythonNative runs a single framework-wide ``asyncio`` event loop **on
the platform's main thread**, pumped as a guest of the native run loop
(``dispatch_async`` on iOS, ``Handler.post`` on Android, the Tk poll
loop in ``pn preview``). Every awaitable surface in the framework
schedules its work on this loop: ``async def`` components,
coroutine [`use_effect`][pythonnative.use_effect] callbacks,
[`use_resource`][pythonnative.use_resource],
[`use_query`][pythonnative.hooks.use_query],
[`use_mutation`][pythonnative.hooks.use_mutation],
[`fetch`][pythonnative.net.fetch],
Expand Down
29 changes: 29 additions & 0 deletions docs/api/suspense.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Suspense

Primitives behind PythonNative's async rendering model: the
[`Suspend`][pythonnative.suspense.Suspend] signal, the
[`CoroDriver`][pythonnative.suspense.CoroDriver] that steps `async def`
component bodies synchronously, cached async values
([`Resource`][pythonnative.Resource] /
[`start_resource`][pythonnative.start_resource]), and code splitting
with [`lazy`][pythonnative.lazy].

The user-facing pieces are the [`Suspense`][pythonnative.Suspense]
boundary component (documented with the other
[components](components.md)) and the
[`use_resource`][pythonnative.use_resource] hook (documented with the
other [hooks](hooks.md)); this page covers the underlying machinery.

::: pythonnative.suspense
options:
show_root_heading: false
show_root_toc_entry: false
members_order: source
filters: ["!^_"]

## Next steps

- Walk through async components, `Suspense`, and `use_resource`
end-to-end: [Async + data guide](../guides/async.md).
- See how suspension threads through the render pass:
[Architecture](../concepts/architecture.md).
46 changes: 43 additions & 3 deletions docs/concepts/architecture.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,66 @@ platform APIs synchronously from Python.
[`ErrorBoundary`][pythonnative.ErrorBoundary] catches render
errors in child subtrees and displays fallback UI, preventing a
single component failure from crashing the entire page.
8. **Direct bindings.** Under the hood, native views are created and
8. **Async components and Suspense.** A component body may be an
`async def`. The reconciler drives it synchronously as far as it
can (awaits on already-resolved data complete inline); if it
blocks on pending work the render *suspends* and the nearest
[`Suspense`][pythonnative.Suspense] boundary shows its fallback
until the awaited work finishes.
[`use_resource`][pythonnative.use_resource] starts fetches during
render, and [`lazy`][pythonnative.lazy] code-splits components
behind the same mechanism.
9. **Direct bindings.** Under the hood, native views are created and
updated through direct platform calls:
- **iOS**: rubicon-objc exposes Objective-C/Swift classes
(`UILabel`, `UIButton`, `UIStackView`, etc.).
- **Android**: Chaquopy exposes Java classes
(`android.widget.TextView`, `android.widget.Button`, etc.) via
the JNI bridge.
9. **Thin native bootstrap.** The host app remains native (Android
10. **Thin native bootstrap.** The host app remains native (Android
`Activity` or iOS `UIViewController`). It calls
[`create_screen`][pythonnative.create_screen] internally to bootstrap
your Python component, and the reconciler drives the UI from
there.
10. **`App` entry point.** The user's app module (`app/main.py`)
11. **`App` entry point.** The user's app module (`app/main.py`)
defines a top-level component named `App`. Native templates
import that module by path (`"app.main"`) and look up its `App`
attribute, so users never write a separate registration step.
Components with other names can still be loaded by passing an
explicit dotted path like `"app.main.RootScreen"` to the
template.

## Concurrency model

PythonNative is **async-first**: one `asyncio` event loop hosts every
coroutine in the framework (async component bodies, effects,
resources, native modules, animations, timers), and that loop lives
**on the platform's main thread**.

Because UIKit and the Android view system own the main thread's run
loop, the framework loop can't call `run_forever` and block. It runs
as a *guest* instead: whenever async work is scheduled, the runtime
asks the platform to pump the loop on the next main-queue turn
(`dispatch_async` on iOS, `Handler.post` on Android, the Tk poll loop
in `pn preview`). One pump runs every ready callback and due timer,
then hands control back to the platform.

The payoff is that coroutines and rendering interleave on one thread:

- Async code can call state setters, read hook values, and (through
the commit) touch native views without cross-thread marshaling.
- There are no locks and no thread-affinity bugs; "which thread am I
on" stops being a question.
- Hook state is carried in `contextvars`, so a component that awaits
in the middle of its body still resolves its hooks against the
right instance when it resumes.

Synchronous scripts and tests drive the loop explicitly with
[`run_blocking`][pythonnative.runtime.run_blocking] and
[`drain`][pythonnative.runtime.drain]; async tests just `await`,
since [`get_loop`][pythonnative.runtime.get_loop] adopts an
already-running loop.

## How it works

```text
Expand Down
32 changes: 27 additions & 5 deletions docs/concepts/hooks.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,10 @@ Run side effects **after** the native view tree is committed. The effect functio
def Timer():
seconds, set_seconds = pn.use_state(0)

def tick():
import threading
t = threading.Timer(1.0, lambda: set_seconds(seconds + 1))
t.start()
return t.cancel # cleanup: cancel the timer
async def tick():
import asyncio
await asyncio.sleep(1.0)
set_seconds(seconds + 1)

pn.use_effect(tick, [seconds])

Expand All @@ -119,6 +118,12 @@ executed after the reconciler finishes committing native view
mutations. This means effect callbacks can safely measure layout or
interact with the committed native tree.

The effect callback may be an `async def` (as above); the coroutine
runs as a task on the framework loop and is **cancelled
automatically** when the effect re-runs or the component unmounts, so
the sleeping tick above never fires against an unmounted component.
See the [Async + data guide](../guides/async.md).

Dependency control:

- `pn.use_effect(fn, None)`: run on every render.
Expand Down Expand Up @@ -307,6 +312,23 @@ theme = pn.use_context(pn.ThemeContext)
color = theme["primary_color"]
```

### Async hooks

Data fetching and priority scheduling have dedicated hooks, covered in
the [Async + data guide](../guides/async.md):

- [`use_resource`][pythonnative.use_resource]: start a fetch during
render; reading a pending resource suspends until the nearest
[`Suspense`][pythonnative.Suspense] boundary's fallback resolves.
- [`use_query`][pythonnative.use_query] /
[`use_mutation`][pythonnative.use_mutation]: subscribe to an async
fetcher / wrap an async mutator with loading and error state.
- [`use_transition`][pythonnative.use_transition] /
[`use_deferred_value`][pythonnative.use_deferred_value]: mark
expensive updates as low priority so urgent updates paint first.
- [`use_persisted_state`][pythonnative.use_persisted_state]:
`use_state` backed by [`AsyncStorage`][pythonnative.AsyncStorage].

## Context and Provider

Share values through the component tree without passing props manually:
Expand Down
6 changes: 4 additions & 2 deletions docs/concepts/layout.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,12 @@ private attributes.
The engine intentionally does **not** implement:

- `position: "fixed"`.
- `wrap` / `flex_wrap` (single-line flex only for now).
- RTL flipping (use `row_reverse` / `column_reverse` explicitly).
- Float / inline / table / grid layouts.

Wrapping (`flex_wrap: "wrap"` / `"wrap_reverse"`) and RTL flipping
(`direction: "rtl"`, with `start` / `end` edge insets resolving against
the inherited direction) are both supported.

Everything else from the React Native flexbox cheat-sheet is
supported.

Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL