pythonnative 0.19.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. {pythonnative-0.19.0/src/pythonnative.egg-info → pythonnative-0.20.0}/PKG-INFO +2 -2
  2. {pythonnative-0.19.0 → pythonnative-0.20.0}/README.md +1 -1
  3. {pythonnative-0.19.0 → pythonnative-0.20.0}/pyproject.toml +2 -1
  4. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/__init__.py +1 -1
  5. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/hooks.py +30 -6
  6. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/reconciler.py +285 -3
  7. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/screen.py +23 -27
  8. {pythonnative-0.19.0 → pythonnative-0.20.0/src/pythonnative.egg-info}/PKG-INFO +2 -2
  9. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative.egg-info/SOURCES.txt +1 -0
  10. pythonnative-0.20.0/tests/test_incremental_render.py +371 -0
  11. {pythonnative-0.19.0 → pythonnative-0.20.0}/LICENSE +0 -0
  12. {pythonnative-0.19.0 → pythonnative-0.20.0}/setup.cfg +0 -0
  13. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/_ios_log.py +0 -0
  14. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/alerts.py +0 -0
  15. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/animated.py +0 -0
  16. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/cli/__init__.py +0 -0
  17. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/cli/pn.py +0 -0
  18. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/components.py +0 -0
  19. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/element.py +0 -0
  20. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/hot_reload.py +0 -0
  21. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/layout.py +0 -0
  22. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/__init__.py +0 -0
  23. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/app_state.py +0 -0
  24. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/battery.py +0 -0
  25. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/biometrics.py +0 -0
  26. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/camera.py +0 -0
  27. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/clipboard.py +0 -0
  28. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/file_system.py +0 -0
  29. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/haptics.py +0 -0
  30. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/linking.py +0 -0
  31. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/location.py +0 -0
  32. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/net_info.py +0 -0
  33. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/notifications.py +0 -0
  34. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/permissions.py +0 -0
  35. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/secure_store.py +0 -0
  36. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_modules/share.py +0 -0
  37. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_views/__init__.py +0 -0
  38. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_views/android.py +0 -0
  39. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_views/base.py +0 -0
  40. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_views/desktop.py +0 -0
  41. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/native_views/ios.py +0 -0
  42. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/navigation.py +0 -0
  43. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/net.py +0 -0
  44. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/platform.py +0 -0
  45. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/platform_metrics.py +0 -0
  46. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/preview.py +0 -0
  47. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/runtime.py +0 -0
  48. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/sdk/__init__.py +0 -0
  49. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/sdk/_components.py +0 -0
  50. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/storage.py +0 -0
  51. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/style.py +0 -0
  52. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/build.gradle +0 -0
  53. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/proguard-rules.pro +0 -0
  54. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/androidTest/java/com/pythonnative/android_template/ExampleInstrumentedTest.kt +0 -0
  55. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +0 -0
  56. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +0 -0
  57. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -0
  58. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -0
  59. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +0 -0
  60. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable/ic_launcher_background.xml +0 -0
  61. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -0
  62. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/layout/activity_main.xml +0 -0
  63. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -0
  64. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -0
  65. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
  66. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +0 -0
  67. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
  68. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +0 -0
  69. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
  70. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +0 -0
  71. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
  72. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +0 -0
  73. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
  74. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +0 -0
  75. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +0 -0
  76. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/values/colors.xml +0 -0
  77. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/values/strings.xml +0 -0
  78. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/values/themes.xml +0 -0
  79. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/values-night/themes.xml +0 -0
  80. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/backup_rules.xml +0 -0
  81. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/data_extraction_rules.xml +0 -0
  82. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/app/src/test/java/com/pythonnative/android_template/ExampleUnitTest.kt +0 -0
  83. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/build.gradle +0 -0
  84. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.jar +0 -0
  85. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +0 -0
  86. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/gradle.properties +0 -0
  87. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/gradlew +0 -0
  88. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/gradlew.bat +0 -0
  89. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/android_template/settings.gradle +0 -0
  90. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/AppDelegate.swift +0 -0
  91. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AccentColor.colorset/Contents.json +0 -0
  92. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -0
  93. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/Contents.json +0 -0
  94. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/LaunchScreen.storyboard +0 -0
  95. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -0
  96. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/Info.plist +0 -0
  97. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +0 -0
  98. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +0 -0
  99. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +0 -0
  100. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -0
  101. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift +0 -0
  102. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +0 -0
  103. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift +0 -0
  104. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative/utils.py +0 -0
  105. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative.egg-info/dependency_links.txt +0 -0
  106. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative.egg-info/entry_points.txt +0 -0
  107. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative.egg-info/requires.txt +0 -0
  108. {pythonnative-0.19.0 → pythonnative-0.20.0}/src/pythonnative.egg-info/top_level.txt +0 -0
  109. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_alert.py +0 -0
  110. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_animated.py +0 -0
  111. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_async_hooks.py +0 -0
  112. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_cli.py +0 -0
  113. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_components.py +0 -0
  114. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_desktop_backend.py +0 -0
  115. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_element.py +0 -0
  116. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_extended_components.py +0 -0
  117. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_hooks.py +0 -0
  118. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_hot_reload.py +0 -0
  119. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_ios_log.py +0 -0
  120. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_layout.py +0 -0
  121. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_metric_hooks.py +0 -0
  122. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_native_modules.py +0 -0
  123. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_native_views.py +0 -0
  124. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_navigation.py +0 -0
  125. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_net.py +0 -0
  126. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_new_components.py +0 -0
  127. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_platform.py +0 -0
  128. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_platform_metrics.py +0 -0
  129. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_reconciler.py +0 -0
  130. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_ref.py +0 -0
  131. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_runtime.py +0 -0
  132. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_screen.py +0 -0
  133. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_sdk.py +0 -0
  134. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_smoke.py +0 -0
  135. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_storage.py +0 -0
  136. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_style.py +0 -0
  137. {pythonnative-0.19.0 → pythonnative-0.20.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -102,7 +102,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
102
102
  - **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern.
103
103
  - **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`.
104
104
  - **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.
105
- - **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation.
105
+ - **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.
106
106
  - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
107
107
  - **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.
108
108
  - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
@@ -36,7 +36,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
36
36
  - **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern.
37
37
  - **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`.
38
38
  - **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.
40
40
  - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
41
41
  - **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.
42
42
  - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythonnative"
7
- version = "0.19.0"
7
+ version = "0.20.0"
8
8
  description = "Cross-platform native UI toolkit for Android and iOS"
9
9
  authors = [
10
10
  { name = "Owen Carey" }
@@ -72,6 +72,7 @@ Documentation = "https://docs.pythonnative.com/"
72
72
 
73
73
 
74
74
 
75
+
75
76
  [tool.setuptools.packages.find]
76
77
  where = ["src"]
77
78
 
@@ -51,7 +51,7 @@ Example:
51
51
  ```
52
52
  """
53
53
 
54
- __version__ = "0.19.0"
54
+ __version__ = "0.20.0"
55
55
 
56
56
  from . import runtime, sdk
57
57
  from .alerts import Alert
@@ -76,6 +76,8 @@ class HookState:
76
76
  "_trigger_render",
77
77
  "_pending_effects",
78
78
  "_dirty",
79
+ "_vnode",
80
+ "_reconciler",
79
81
  )
80
82
 
81
83
  def __init__(self) -> None:
@@ -95,6 +97,13 @@ class HookState:
95
97
  # knows that a memoized component still needs to re-render even
96
98
  # when its props didn't change.
97
99
  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
98
107
 
99
108
  def reset_index(self) -> None:
100
109
  """Reset every per-hook cursor to ``0``.
@@ -182,6 +191,25 @@ def _schedule_trigger(trigger: Callable[[], None]) -> None:
182
191
  trigger()
183
192
 
184
193
 
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
+
185
213
  @contextmanager
186
214
  def batch_updates() -> Generator[None, None, None]:
187
215
  """Coalesce multiple state updates into a single re-render.
@@ -272,9 +300,7 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
272
300
  new_value = new_value(ctx.states[idx])
273
301
  if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
274
302
  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)
278
304
 
279
305
  return current, setter
280
306
 
@@ -339,9 +365,7 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
339
365
  new_state = reducer(ctx.states[idx], action)
340
366
  if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
341
367
  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)
345
369
 
346
370
  return current, dispatch
347
371
 
@@ -30,7 +30,7 @@ Supports:
30
30
 
31
31
  import os
32
32
  import sys
33
- from typing import Any, List, Optional, Tuple
33
+ from typing import Any, Dict, List, Optional, Tuple
34
34
 
35
35
  from .element import Element
36
36
  from .layout import LayoutNode, calculate_layout, extract_layout_style
@@ -106,19 +106,44 @@ class VNode:
106
106
  or an iOS `UIView`). May be `None` for purely virtual
107
107
  wrappers such as providers and error boundaries.
108
108
  children: Ordered list of child `VNode` instances.
109
+ parent: The owning `VNode`, or `None` for the tree root. Used
110
+ by local (component-scoped) re-renders to bubble a changed
111
+ native view up to the nearest native container.
109
112
  hook_state: The component's
110
113
  [`HookState`][pythonnative.hooks.HookState] when the node
111
114
  wraps a function component, otherwise `None`.
115
+ mounted: `False` once the node has been destroyed, so stale
116
+ entries in the reconciler's dirty set are skipped.
112
117
  """
113
118
 
114
- __slots__ = ("element", "native_view", "children", "hook_state", "_rendered")
119
+ __slots__ = (
120
+ "element",
121
+ "native_view",
122
+ "children",
123
+ "parent",
124
+ "hook_state",
125
+ "mounted",
126
+ "_rendered",
127
+ "_measure_cache",
128
+ )
115
129
 
116
130
  def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None:
117
131
  self.element = element
118
132
  self.native_view = native_view
119
133
  self.children = children
134
+ self.parent: Optional["VNode"] = None
120
135
  self.hook_state: Any = None
136
+ self.mounted: bool = True
121
137
  self._rendered: Optional[Element] = None
138
+ # Cache for the leaf intrinsic-size measure callback:
139
+ # ``(element, max_w, max_h, width, height)``. Lets the layout
140
+ # pass skip native ``measure_intrinsic`` calls for leaves whose
141
+ # element *object* (compared by identity) and constraints are
142
+ # unchanged since the last measure. Because untouched components
143
+ # keep their exact ``Element`` instances across a local
144
+ # re-render, this turns "re-measure everything every layout pass"
145
+ # into "re-measure only what actually re-rendered".
146
+ self._measure_cache: Optional[Tuple[Any, float, float, float, float]] = None
122
147
 
123
148
 
124
149
  class Reconciler:
@@ -142,6 +167,11 @@ class Reconciler:
142
167
  self._screen_re_render: Optional[Any] = None
143
168
  self._viewport_size: Tuple[float, float] = (0.0, 0.0)
144
169
  self._layout_pass = 0
170
+ # Function-component VNodes whose own state changed since the
171
+ # last flush, keyed by ``id`` to dedupe while keeping a strong
172
+ # reference. Drained by
173
+ # [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty].
174
+ self._dirty_nodes: Dict[int, VNode] = {}
145
175
 
146
176
  # ------------------------------------------------------------------
147
177
  # Public API
@@ -160,6 +190,7 @@ class Reconciler:
160
190
  self._log_viewport(
161
191
  f"mount: start type={self._type_label(element.type)!r} props={self._props_debug(element.props)}"
162
192
  )
193
+ self._dirty_nodes.clear()
163
194
  self._tree = self._create_tree(element)
164
195
  self._log_viewport(f"mount: tree created root={self._node_debug(self._tree)}")
165
196
  self._flush_effects()
@@ -181,6 +212,9 @@ class Reconciler:
181
212
  f"(have_tree={self._tree is not None}) new_type={self._type_label(new_element.type)!r} "
182
213
  f"new_props={self._props_debug(new_element.props)}"
183
214
  )
215
+ # A full reconcile rebuilds the whole tree from the root, so any
216
+ # pending per-component dirty marks are now obsolete.
217
+ self._dirty_nodes.clear()
184
218
  if self._tree is None:
185
219
  self._tree = self._create_tree(new_element)
186
220
  self._log_viewport(f"reconcile: created initial root={self._node_debug(self._tree)}")
@@ -195,6 +229,221 @@ class Reconciler:
195
229
  self._log_viewport("reconcile: done")
196
230
  return self._tree.native_view
197
231
 
232
+ def root_view(self) -> Any:
233
+ """Return the current root native view, or ``None`` before mount."""
234
+ return self._tree.native_view if self._tree is not None else None
235
+
236
+ def mark_dirty(self, vnode: "VNode") -> None:
237
+ """Queue ``vnode`` (a function component) for a local re-render.
238
+
239
+ Called by a component's ``use_state`` / ``use_reducer`` setter
240
+ when its own state changes. The node is re-rendered on the next
241
+ [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
242
+ pass, which the screen host schedules. Marking is idempotent and
243
+ cheap; the actual render is deferred so several setters (e.g.
244
+ inside [`batch_updates`][pythonnative.batch_updates]) coalesce
245
+ into a single pass.
246
+ """
247
+ if vnode is None or vnode.hook_state is None or not vnode.mounted:
248
+ return
249
+ self._dirty_nodes[id(vnode)] = vnode
250
+
251
+ def flush_dirty(self) -> Any:
252
+ """Re-render only the component subtrees marked dirty since the last pass.
253
+
254
+ This is the hot path for state-driven updates: instead of
255
+ re-running the whole app from the root, each dirty function
256
+ component re-runs its own body (reusing its
257
+ [`HookState`][pythonnative.hooks.HookState]) and reconciles just
258
+ its subtree. Nodes are processed shallowest-first so that when a
259
+ dirty ancestor's re-render already covers a dirty descendant, the
260
+ descendant is skipped (its ``_dirty`` flag is cleared by the
261
+ ancestor pass).
262
+
263
+ Returns:
264
+ The (possibly replaced) root native view, so the host can
265
+ re-attach it if the root changed.
266
+ """
267
+ if self._tree is None:
268
+ return None
269
+ if not self._dirty_nodes:
270
+ return self._tree.native_view
271
+
272
+ pending = list(self._dirty_nodes.values())
273
+ self._dirty_nodes.clear()
274
+ pending.sort(key=self._node_depth)
275
+ for vnode in pending:
276
+ if not vnode.mounted:
277
+ continue
278
+ hook_state = vnode.hook_state
279
+ if hook_state is None or not hook_state._dirty:
280
+ # Already re-rendered as part of a dirty ancestor's pass.
281
+ continue
282
+ try:
283
+ self._update_component(vnode)
284
+ except Exception as exc:
285
+ # A local re-render starts below any enclosing
286
+ # ``ErrorBoundary``, so route the failure to the nearest
287
+ # boundary ancestor (re-rendering its subtree through the
288
+ # boundary, which mounts the fallback). With no boundary
289
+ # the exception propagates, matching a full render.
290
+ self._handle_local_render_error(vnode, exc)
291
+
292
+ self._flush_effects()
293
+ self._run_layout()
294
+ return self._tree.native_view
295
+
296
+ @staticmethod
297
+ def _node_depth(vnode: "VNode") -> int:
298
+ depth = 0
299
+ node = vnode.parent
300
+ while node is not None:
301
+ depth += 1
302
+ node = node.parent
303
+ return depth
304
+
305
+ def _update_component(self, vnode: "VNode") -> None:
306
+ """Re-run one function component's body and reconcile its subtree in place.
307
+
308
+ Unlike a full reconcile from the root, a local update starts in
309
+ the *middle* of the tree, so the context stack of every
310
+ ``__Provider__`` ancestor must be re-established before the body
311
+ runs (otherwise [`use_context`][pythonnative.use_context] — and
312
+ therefore [`use_navigation`][pythonnative.use_navigation] — would
313
+ read the context default instead of the provided value). Nested
314
+ providers *inside* this subtree are pushed/popped normally by the
315
+ recursive reconcile beneath us.
316
+ """
317
+ from .hooks import _set_hook_state
318
+
319
+ new_el = vnode.element
320
+ if not callable(new_el.type):
321
+ return
322
+ hook_state = vnode.hook_state
323
+ if hook_state is None:
324
+ return
325
+
326
+ providers = self._ancestor_providers(vnode)
327
+ for context, value in providers:
328
+ context._stack.append(value)
329
+ try:
330
+ hook_state.reset_index()
331
+ hook_state._trigger_render = self._screen_re_render
332
+ hook_state._vnode = vnode
333
+ hook_state._reconciler = self
334
+ _set_hook_state(hook_state)
335
+ try:
336
+ rendered = new_el.type(**new_el.props)
337
+ finally:
338
+ _set_hook_state(None)
339
+ hook_state._dirty = False
340
+
341
+ old_native = vnode.native_view
342
+ if vnode.children:
343
+ child = self._reconcile_node(vnode.children[0], rendered)
344
+ else:
345
+ child = self._create_tree(rendered)
346
+ finally:
347
+ for context, _value in reversed(providers):
348
+ context._stack.pop()
349
+
350
+ child.parent = vnode
351
+ vnode.children = [child]
352
+ vnode.native_view = child.native_view
353
+ vnode._rendered = rendered
354
+
355
+ if child.native_view is not old_native:
356
+ self._bubble_native_view_change(vnode, old_native, child.native_view)
357
+
358
+ def _handle_local_render_error(self, vnode: "VNode", exc: Exception) -> None:
359
+ """Route a local re-render failure to the nearest ``ErrorBoundary`` ancestor.
360
+
361
+ Re-reconciles the boundary against its own element so the throw
362
+ is re-triggered *inside*
363
+ [`_reconcile_error_boundary`][pythonnative.reconciler.Reconciler._reconcile_error_boundary],
364
+ which destroys the failed subtree and mounts the boundary's
365
+ fallback. If no boundary encloses ``vnode`` the exception
366
+ propagates, exactly as it would during a full render.
367
+ """
368
+ node = vnode.parent
369
+ while node is not None:
370
+ if isinstance(node.element.type, str) and node.element.type == "__ErrorBoundary__":
371
+ old_native = node.native_view
372
+ # Like a local component update, this re-reconcile starts
373
+ # mid-tree, so restore the boundary's own ancestor
374
+ # provider context first.
375
+ providers = self._ancestor_providers(node)
376
+ for context, value in providers:
377
+ context._stack.append(value)
378
+ try:
379
+ self._reconcile_node(node, node.element)
380
+ finally:
381
+ for context, _value in reversed(providers):
382
+ context._stack.pop()
383
+ if node.native_view is not old_native:
384
+ self._bubble_native_view_change(node, old_native, node.native_view)
385
+ return
386
+ node = node.parent
387
+ raise exc
388
+
389
+ @staticmethod
390
+ def _ancestor_providers(vnode: "VNode") -> List[Tuple[Any, Any]]:
391
+ """Collect ``(context, value)`` for every ``__Provider__`` above ``vnode``.
392
+
393
+ Returned outermost-first so that pushing them in order leaves the
394
+ nearest provider on top of each context's stack (nearest wins,
395
+ matching React).
396
+ """
397
+ chain: List[Tuple[Any, Any]] = []
398
+ node = vnode.parent
399
+ while node is not None:
400
+ el = node.element
401
+ if isinstance(el.type, str) and el.type == "__Provider__":
402
+ chain.append((el.props["__context__"], el.props["__value__"]))
403
+ node = node.parent
404
+ chain.reverse()
405
+ return chain
406
+
407
+ def _bubble_native_view_change(self, vnode: "VNode", old_native: Any, new_native: Any) -> None:
408
+ """Propagate a changed subtree-root native view up to its native parent.
409
+
410
+ A local re-render starts below the real native container, so when
411
+ the dirty component's root native view is swapped (e.g. its output
412
+ changed type), the change must be reflected in (a) every
413
+ transparent ancestor that delegated its ``native_view`` to this
414
+ subtree and (b) the nearest native-container ancestor's child list.
415
+ """
416
+ child = vnode
417
+ node = vnode.parent
418
+ while node is not None:
419
+ if self._is_native_container(node):
420
+ try:
421
+ idx: Optional[int] = node.children.index(child)
422
+ except ValueError:
423
+ idx = None
424
+ if old_native is not None:
425
+ self.backend.remove_child(node.native_view, old_native, node.element.type)
426
+ if new_native is not None:
427
+ if idx is None:
428
+ self.backend.add_child(node.native_view, new_native, node.element.type)
429
+ else:
430
+ self.backend.insert_child(node.native_view, new_native, node.element.type, idx)
431
+ return
432
+ # Transparent ancestor (component / provider / error boundary /
433
+ # fragment) delegates its native view to this subtree.
434
+ if node.native_view is old_native:
435
+ node.native_view = new_native
436
+ child = node
437
+ node = node.parent
438
+ # Reached the root with no native container above: the root's
439
+ # ``native_view`` was already updated in the loop. The host
440
+ # detects the change by comparing ``root_view()`` after the flush.
441
+
442
+ @staticmethod
443
+ def _is_native_container(node: "VNode") -> bool:
444
+ t = node.element.type
445
+ return isinstance(t, str) and t not in ("__Provider__", "__ErrorBoundary__", "__Fragment__")
446
+
198
447
  def set_viewport_size(self, width: float, height: float) -> None:
199
448
  """Update the viewport size and re-run layout if it changed.
200
449
 
@@ -288,12 +537,24 @@ class Reconciler:
288
537
  # ------------------------------------------------------------------
289
538
 
290
539
  def _flush_effects(self) -> None:
291
- """Walk the committed tree and flush pending effects (depth-first)."""
540
+ """Walk the committed tree and flush pending effects (depth-first).
541
+
542
+ This post-commit walk doubles as the single source of truth for
543
+ ``VNode.parent``: every live node's parent pointer is re-linked
544
+ here so that local re-renders
545
+ ([`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty])
546
+ can compute node depth and bubble native-view changes upward
547
+ without each reconcile path having to maintain parent links by
548
+ hand. The cost is folded into a walk the reconciler already runs
549
+ after every commit.
550
+ """
292
551
  if self._tree is not None:
552
+ self._tree.parent = None
293
553
  self._flush_tree_effects(self._tree)
294
554
 
295
555
  def _flush_tree_effects(self, node: VNode) -> None:
296
556
  for child in node.children:
557
+ child.parent = node
297
558
  self._flush_tree_effects(child)
298
559
  if node.hook_state is not None:
299
560
  node.hook_state.flush_pending_effects()
@@ -347,6 +608,8 @@ class Reconciler:
347
608
  vnode = VNode(element, child_node.native_view, [child_node])
348
609
  vnode.hook_state = hook_state
349
610
  vnode._rendered = rendered
611
+ hook_state._vnode = vnode
612
+ hook_state._reconciler = self
350
613
  return vnode
351
614
 
352
615
  # Native element
@@ -478,6 +741,8 @@ class Reconciler:
478
741
  old.element = new_el
479
742
  old.hook_state = hook_state
480
743
  old._rendered = rendered
744
+ hook_state._vnode = old
745
+ hook_state._reconciler = self
481
746
  return old
482
747
 
483
748
  # Native element
@@ -667,13 +932,25 @@ class Reconciler:
667
932
  )
668
933
 
669
934
  def _destroy_tree(self, node: VNode) -> None:
935
+ node.mounted = False
936
+ # Drop the node from the pending-render set so a setter that
937
+ # fired moments before unmount can't resurrect a dead subtree.
938
+ self._dirty_nodes.pop(id(node), None)
670
939
  if node.hook_state is not None:
671
940
  node.hook_state.cleanup_all_effects()
941
+ # Break the back-references so the unmounted component's hook
942
+ # state (and the closures it captured) can be freed by plain
943
+ # refcounting — important on iOS, where the cyclic GC is
944
+ # disabled.
945
+ node.hook_state._vnode = None
946
+ node.hook_state._reconciler = None
947
+ node.hook_state._trigger_render = None
672
948
  if node.element is not None:
673
949
  self._detach_ref(node.element)
674
950
  for child in node.children:
675
951
  self._destroy_tree(child)
676
952
  node.children = []
953
+ node.parent = None
677
954
 
678
955
  @staticmethod
679
956
  def _strip_reconciler_props(props: dict) -> dict:
@@ -935,6 +1212,10 @@ class Reconciler:
935
1212
  node_label = self._node_debug(vnode)
936
1213
 
937
1214
  def measure(max_w: float, max_h: float) -> Tuple[float, float]:
1215
+ cache = vnode._measure_cache
1216
+ if cache is not None and cache[0] is vnode.element and cache[1] == max_w and cache[2] == max_h:
1217
+ self._log_viewport(f"measure: cache hit type={type_name!r} result=({cache[3]!r},{cache[4]!r})")
1218
+ return (cache[3], cache[4])
938
1219
  try:
939
1220
  self._log_viewport(
940
1221
  "measure: before backend.measure_intrinsic " f"{node_label} max=({max_w!r},{max_h!r})"
@@ -942,6 +1223,7 @@ class Reconciler:
942
1223
  w, h = backend.measure_intrinsic(view, type_name, max_w, max_h)
943
1224
  result = (float(w), float(h))
944
1225
  self._log_viewport(f"measure: after backend.measure_intrinsic type={type_name!r} result={result!r}")
1226
+ vnode._measure_cache = (vnode.element, max_w, max_h, result[0], result[1])
945
1227
  return result
946
1228
  except Exception as e:
947
1229
  self._log_viewport(
@@ -322,53 +322,49 @@ def _request_render(host: Any) -> None:
322
322
 
323
323
 
324
324
  def _re_render(host: Any) -> None:
325
- """Run one render pass, then drain any renders queued during it."""
326
- from .hooks import Provider, _NavigationContext
327
-
328
- _log_pn("_re_render: starting render pass")
325
+ """Run one *local* render pass, then drain any renders queued during it.
326
+
327
+ State setters mark only their own component subtree dirty (see
328
+ [`mark_dirty`][pythonnative.reconciler.Reconciler.mark_dirty]), so
329
+ this drains the reconciler's dirty set via
330
+ [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
331
+ instead of re-running the whole ``App`` from the root. The app's
332
+ element tree is only rebuilt from scratch on mount, navigation, and
333
+ hot reload.
334
+ """
335
+ _log_pn("_re_render: starting local render pass")
329
336
  host._is_rendering = True
330
337
  try:
331
338
  host._render_queued = False
332
-
333
- app_element = _render_app(host)
334
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
335
-
336
- new_root = host._reconciler.reconcile(provider_element)
337
- if new_root is not host._root_native_view:
338
- _log_pn(f"_re_render: ROOT VIEW CHANGED ({id(host._root_native_view)} -> {id(new_root)}); reattaching")
339
- host._detach_root(host._root_native_view)
340
- host._root_native_view = new_root
341
- host._attach_root(new_root)
342
-
339
+ _commit_dirty(host)
343
340
  _drain_renders(host)
344
341
  finally:
345
342
  host._is_rendering = False
346
343
  _log_pn("_re_render: done")
347
344
 
348
345
 
346
+ def _commit_dirty(host: Any) -> None:
347
+ """Flush the reconciler's dirty components and re-attach the root if it changed."""
348
+ new_root = host._reconciler.flush_dirty()
349
+ if new_root is not host._root_native_view:
350
+ _log_pn(f"_commit_dirty: ROOT VIEW CHANGED ({id(host._root_native_view)} -> {id(new_root)}); reattaching")
351
+ host._detach_root(host._root_native_view)
352
+ host._root_native_view = new_root
353
+ host._attach_root(new_root)
354
+
355
+
349
356
  def _drain_renders(host: Any) -> None:
350
357
  """Flush additional renders queued by effects that set state.
351
358
 
352
359
  Capped at `_MAX_RENDER_PASSES` to break runaway feedback loops
353
360
  (e.g., an effect that unconditionally calls a setter).
354
361
  """
355
- from .hooks import Provider, _NavigationContext
356
-
357
362
  for i in range(_MAX_RENDER_PASSES):
358
363
  if not host._render_queued:
359
364
  break
360
365
  _log_pn(f"_drain_renders: draining pass #{i + 1}")
361
366
  host._render_queued = False
362
-
363
- app_element = _render_app(host)
364
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
365
-
366
- new_root = host._reconciler.reconcile(provider_element)
367
- if new_root is not host._root_native_view:
368
- _log_pn(f"_drain_renders: ROOT VIEW CHANGED ({id(host._root_native_view)} -> {id(new_root)}); reattaching")
369
- host._detach_root(host._root_native_view)
370
- host._root_native_view = new_root
371
- host._attach_root(new_root)
367
+ _commit_dirty(host)
372
368
 
373
369
 
374
370
  def _set_args(host: Any, args: Any) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -102,7 +102,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
102
102
  - **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern.
103
103
  - **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`.
104
104
  - **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.
105
- - **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation.
105
+ - **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.
106
106
  - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
107
107
  - **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.
108
108
  - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
@@ -113,6 +113,7 @@ tests/test_element.py
113
113
  tests/test_extended_components.py
114
114
  tests/test_hooks.py
115
115
  tests/test_hot_reload.py
116
+ tests/test_incremental_render.py
116
117
  tests/test_ios_log.py
117
118
  tests/test_layout.py
118
119
  tests/test_metric_hooks.py