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

feat(reconciler)!: re-render only dirty components, not the whole tree by owenthcarey · Pull Request #9 · 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  (3) .py  (4) All 2 file types selected
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
2 changes: 1 addition & 1 deletion 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 @@ -36,7 +36,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
- **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern.
- **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.
- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation.
- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. State updates re-render **locally** — only the component whose state changed (and its subtree) re-runs, and unchanged leaves reuse cached intrinsic measurements — so deep UIs stay responsive instead of re-rendering the whole app from the root on every tap.
- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
- **Custom-component SDK:** Wrap any platform widget as a first-class element with type-checked props via `pythonnative.sdk` (`Props`, `@native_component`, `element_factory`). Plugins distributed on PyPI auto-register through the `pythonnative.handlers` entry-point group.
- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
Expand Down
39 changes: 29 additions & 10 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 @@ -19,9 +19,14 @@ platform APIs synchronously from Python.
3. **Reconciler.** On first render, the
[`Reconciler`][pythonnative.reconciler.Reconciler] walks the tree
and creates real native views via the platform backend. On
subsequent renders (triggered by hook state changes), it diffs the
new tree against the previous one and applies the minimal set of
native mutations.
subsequent renders it diffs against the previous tree and applies
the minimal set of native mutations. State-driven renders are
**local**: a setter marks only its own component subtree dirty, and
[`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
re-runs just those components instead of the whole app from the
root (the full tree is only rebuilt on mount, navigation, and hot
reload). Sibling and ancestor components whose state did not change
are left untouched.
4. **Post-render effects.** Effects queued via
[`use_effect`][pythonnative.use_effect] are flushed **after** the
reconciler commits native mutations, matching React semantics.
Expand Down Expand Up @@ -78,15 +83,29 @@ and a new one is created.

### Render lifecycle

1. **Render phase**: component functions execute. Hooks record state
reads, queue effects, and register memos. No native mutations
happen yet.
2. **Commit phase**: the reconciler applies the diff to native views,
creating, updating, and removing views as needed.
3. **Effect phase**: pending effects are flushed in depth-first order
1. **Schedule phase**: a `use_state` / `use_reducer` setter that
actually changes a value marks its owning component dirty (adding
the component's node to the reconciler's dirty set) and asks the
screen host to schedule a flush. Several setters coalesce into one
flush.
2. **Render phase**: only the dirty components execute, shallowest
first so a dirty ancestor's re-render subsumes any dirty descendant.
Each dirty component re-runs its body against its preserved hook
state, with the context stack of every enclosing `Provider` restored
so [`use_context`][pythonnative.use_context] still resolves. Hooks
record state reads, queue effects, and register memos. No native
mutations happen yet.
3. **Commit phase**: the reconciler applies the diff for each
re-rendered subtree to native views, creating, updating, and
removing views as needed, and bubbles any swapped subtree-root view
up to its nearest native container.
4. **Layout phase**: a layout pass recomputes frames. Leaves whose
`Element` is unchanged reuse a cached intrinsic measurement, so
untouched subtrees skip native `measure_intrinsic` calls.
5. **Effect phase**: pending effects are flushed in depth-first order
(children before parents). Cleanup functions from the previous
render run before new effect callbacks.
4. **Drain phase**: if effects set state, a new render pass is
6. **Drain phase**: if effects set state, another flush is
automatically triggered and the cycle repeats (up to a safety
limit to prevent infinite loops).

Expand Down
15 changes: 11 additions & 4 deletions docs/concepts/lifecycle.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 @@ -15,15 +15,22 @@ A render pass is triggered by:
- A navigation event (`navigate`, `go_back`, `replace`).
- A hot-reload module swap (see [Hot reload guide](../guides/hot-reload.md)).

A `use_state` / `use_reducer` setter re-renders **locally**: only the
component that owns the changed state (and the subtree it returns) is
re-run, not the whole app. The full tree is rebuilt from the root only
on initial mount, navigation, and hot reload. Sibling and ancestor
components keep their existing native views and hook state untouched.

The phases:

1. **Render**. Your `@component` function runs. Hooks register state,
1. **Render**. The affected `@component` function(s) run — for a state
change, just the component whose setter fired. Hooks register state,
queue effects, and capture closures. No native widgets change yet,
so this phase is cheap and pure (modulo `use_state` updates).
2. **Commit**. The
[`Reconciler`][pythonnative.reconciler.Reconciler] diffs the new
tree against the previous one and applies the smallest set of
native mutations through the registered
[`Reconciler`][pythonnative.reconciler.Reconciler] diffs the
re-rendered subtree against the previous one and applies the
smallest set of native mutations through the registered
[`ViewHandler`][pythonnative.native_views.base.ViewHandler]s.
3. **Effects**. Cleanup callbacks from the *previous* render run
first; new [`use_effect`][pythonnative.use_effect] callbacks run
Expand Down
36 changes: 30 additions & 6 deletions src/pythonnative/hooks.py
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 @@ -76,6 +76,8 @@ class HookState:
"_trigger_render",
"_pending_effects",
"_dirty",
"_vnode",
"_reconciler",
)

def __init__(self) -> None:
Expand All @@ -95,6 +97,13 @@ def __init__(self) -> None:
# knows that a memoized component still needs to re-render even
# when its props didn't change.
self._dirty: bool = False
# Back-references wired by the reconciler so a state setter can
# mark *its own* component subtree dirty for a local re-render
# (instead of forcing a whole-app re-render from the root). Both
# stay ``None`` until the component is mounted, and are cleared
# again when it unmounts.
self._vnode: Any = None
self._reconciler: Any = None

def reset_index(self) -> None:
"""Reset every per-hook cursor to ``0``.
Expand Down Expand Up @@ -182,6 +191,25 @@ def _schedule_trigger(trigger: Callable[[], None]) -> None:
trigger()


def _notify_state_changed(ctx: "HookState") -> None:
"""Mark ``ctx``'s component dirty and schedule a render after a state change.

Enqueuing the owning ``VNode`` in the reconciler's dirty set is what
makes the subsequent render *local*: the screen host's trigger calls
``flush_dirty``, which re-renders only the components marked here
rather than the whole app. The dirty mark is eager (so several
setters coalesce), while the render trigger respects
[`batch_updates`][pythonnative.batch_updates].
"""
ctx._dirty = True
reconciler = ctx._reconciler
vnode = ctx._vnode
if reconciler is not None and vnode is not None:
reconciler.mark_dirty(vnode)
if ctx._trigger_render:
_schedule_trigger(ctx._trigger_render)


@contextmanager
def batch_updates() -> Generator[None, None, None]:
"""Coalesce multiple state updates into a single re-render.
Expand Down Expand Up @@ -272,9 +300,7 @@ def setter(new_value: Any) -> None:
new_value = new_value(ctx.states[idx])
if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
ctx.states[idx] = new_value
ctx._dirty = True
if ctx._trigger_render:
_schedule_trigger(ctx._trigger_render)
_notify_state_changed(ctx)

return current, setter

Expand Down Expand Up @@ -339,9 +365,7 @@ def dispatch(action: Any) -> None:
new_state = reducer(ctx.states[idx], action)
if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
ctx.states[idx] = new_state
ctx._dirty = True
if ctx._trigger_render:
_schedule_trigger(ctx._trigger_render)
_notify_state_changed(ctx)

return current, dispatch

Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL