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

feat(reconciler)!: re-render only dirty components, not the whole tre… · pythonnative/pythonnative@13b615d · GitHub

Repository navigation

Commit 13b615d

Browse files
authored
feat(reconciler)!: re-render only dirty components, not the whole tree (#9)
1 parent 49f3fcf commit 13b615d

7 files changed

Lines changed: 750 additions & 51 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
3636
- **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern.
3737
- **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`.
3838
- **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.
39-
- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation.
39+
- **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.
4040
- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
4141
- **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.
4242
- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.

‎docs/concepts/architecture.md‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ platform APIs synchronously from Python.
1919
3. **Reconciler.** On first render, the
2020
[`Reconciler`][pythonnative.reconciler.Reconciler] walks the tree
2121
and creates real native views via the platform backend. On
22-
subsequent renders (triggered by hook state changes), it diffs the
23-
new tree against the previous one and applies the minimal set of
24-
native mutations.
22+
subsequent renders it diffs against the previous tree and applies
23+
the minimal set of native mutations. State-driven renders are
24+
**local**: a setter marks only its own component subtree dirty, and
25+
[`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
26+
re-runs just those components instead of the whole app from the
27+
root (the full tree is only rebuilt on mount, navigation, and hot
28+
reload). Sibling and ancestor components whose state did not change
29+
are left untouched.
2530
4. **Post-render effects.** Effects queued via
2631
[`use_effect`][pythonnative.use_effect] are flushed **after** the
2732
reconciler commits native mutations, matching React semantics.
@@ -78,15 +83,29 @@ and a new one is created.
7883

7984
### Render lifecycle
8085

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

‎docs/concepts/lifecycle.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@ A render pass is triggered by:
1515
- A navigation event (`navigate`, `go_back`, `replace`).
1616
- A hot-reload module swap (see [Hot reload guide](../guides/hot-reload.md)).
1717

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

20-
1. **Render**. Your `@component` function runs. Hooks register state,
26+
1. **Render**. The affected `@component` function(s) run — for a state
27+
change, just the component whose setter fired. Hooks register state,
2128
queue effects, and capture closures. No native widgets change yet,
2229
so this phase is cheap and pure (modulo `use_state` updates).
2330
2. **Commit**. The
24-
[`Reconciler`][pythonnative.reconciler.Reconciler] diffs the new
25-
tree against the previous one and applies the smallest set of
26-
native mutations through the registered
31+
[`Reconciler`][pythonnative.reconciler.Reconciler] diffs the
32+
re-rendered subtree against the previous one and applies the
33+
smallest set of native mutations through the registered
2734
[`ViewHandler`][pythonnative.native_views.base.ViewHandler]s.
2835
3. **Effects**. Cleanup callbacks from the *previous* render run
2936
first; new [`use_effect`][pythonnative.use_effect] callbacks run

‎src/pythonnative/hooks.py‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ class HookState:
7676
"_trigger_render",
7777
"_pending_effects",
7878
"_dirty",
79+
"_vnode",
80+
"_reconciler",
7981
)
8082

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

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

184193

194+
def _notify_state_changed(ctx: "HookState") -> None:
195+
"""Mark ``ctx``'s component dirty and schedule a render after a state change.
196+
197+
Enqueuing the owning ``VNode`` in the reconciler's dirty set is what
198+
makes the subsequent render *local*: the screen host's trigger calls
199+
``flush_dirty``, which re-renders only the components marked here
200+
rather than the whole app. The dirty mark is eager (so several
201+
setters coalesce), while the render trigger respects
202+
[`batch_updates`][pythonnative.batch_updates].
203+
"""
204+
ctx._dirty = True
205+
reconciler = ctx._reconciler
206+
vnode = ctx._vnode
207+
if reconciler is not None and vnode is not None:
208+
reconciler.mark_dirty(vnode)
209+
if ctx._trigger_render:
210+
_schedule_trigger(ctx._trigger_render)
211+
212+
185213
@contextmanager
186214
def batch_updates() -> Generator[None, None, None]:
187215
"""Coalesce multiple state updates into a single re-render.
@@ -272,9 +300,7 @@ def setter(new_value: Any) -> None:
272300
new_value = new_value(ctx.states[idx])
273301
if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
274302
ctx.states[idx] = new_value
275-
ctx._dirty = True
276-
if ctx._trigger_render:
277-
_schedule_trigger(ctx._trigger_render)
303+
_notify_state_changed(ctx)
278304

279305
return current, setter
280306

@@ -339,9 +365,7 @@ def dispatch(action: Any) -> None:
339365
new_state = reducer(ctx.states[idx], action)
340366
if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
341367
ctx.states[idx] = new_state
342-
ctx._dirty = True
343-
if ctx._trigger_render:
344-
_schedule_trigger(ctx._trigger_render)
368+
_notify_state_changed(ctx)
345369

346370
return current, dispatch
347371

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL