- 
          
 - 
                Notifications
    
You must be signed in to change notification settings  - Fork 332
 
Concurrent Renders #1165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
          
     Merged
      
      
    
  
     Merged
                    Concurrent Renders #1165
Changes from 12 commits
      Commits
    
    
            Show all changes
          
          
            22 commits
          
        
        Select commit
          Hold shift + click to select a range
      
      1bc558b
              
                initial work on concurrent renders
              
              
                rmorshea dd37697
              
                concurrent renders
              
              
                rmorshea 41c2431
              
                limit to 3.11
              
              
                rmorshea f681e1b
              
                fix docs
              
              
                rmorshea 387dc05
              
                update changelog
              
              
                rmorshea a4fc2f5
              
                simpler add_effect interface
              
              
                rmorshea b9595ff
              
                improve docstring
              
              
                rmorshea 24575fc
              
                better changelog description
              
              
                rmorshea 8c82bfb
              
                effect function accepts stop event
              
              
                rmorshea 80d3b7a
              
                simplify concurrent render process
              
              
                rmorshea bfb0d5c
              
                test serial renders too
              
              
                rmorshea e9fd21e
              
                remove ready event
              
              
                rmorshea 847277f
              
                fix doc example
              
              
                rmorshea fb4478f
              
                add docstrings
              
              
                rmorshea 3c7a496
              
                use function scope async fixtures
              
              
                rmorshea cd9f527
              
                fix flaky test
              
              
                rmorshea 8477156
              
                rename config option
              
              
                rmorshea fc8e688
              
                move effect kick-off into component did render
              
              
                rmorshea 8559c7b
              
                move effect start to back to layout render
              
              
                rmorshea 1b828ba
              
                try 3.x again
              
              
                rmorshea 6d969ec
              
                require tracerite 1.1.1
              
              
                rmorshea 6036048
              
                fix docs build
              
              
                rmorshea File filter
Filter by extension
Conversations
          Failed to load comments.   
        
        
          
      Loading
        
  Jump to
        
          Jump to file
        
      
      
          Failed to load files.   
        
        
          
      Loading
        
  Diff view
Diff view
There are no files selected for viewing
  
    
      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,59 +1,59 @@ | ||
| name: hatch-run | ||
| 
     | 
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| job-name: | ||
| required: true | ||
| type: string | ||
| hatch-run: | ||
| required: true | ||
| type: string | ||
| runs-on-array: | ||
| required: false | ||
| type: string | ||
| default: '["ubuntu-latest"]' | ||
| python-version-array: | ||
| required: false | ||
| type: string | ||
| default: '["3.x"]' | ||
| node-registry-url: | ||
| required: false | ||
| type: string | ||
| default: "" | ||
| secrets: | ||
| node-auth-token: | ||
| required: false | ||
| pypi-username: | ||
| required: false | ||
| pypi-password: | ||
| required: false | ||
| workflow_call: | ||
| inputs: | ||
| job-name: | ||
| required: true | ||
| type: string | ||
| hatch-run: | ||
| required: true | ||
| type: string | ||
| runs-on-array: | ||
| required: false | ||
| type: string | ||
| default: '["ubuntu-latest"]' | ||
| python-version-array: | ||
| required: false | ||
| type: string | ||
| default: '["3.11"]' | ||
| node-registry-url: | ||
| required: false | ||
| type: string | ||
| default: "" | ||
| secrets: | ||
| node-auth-token: | ||
| required: false | ||
| pypi-username: | ||
| required: false | ||
| pypi-password: | ||
| required: false | ||
| 
     | 
||
| jobs: | ||
| hatch: | ||
| name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} | ||
| strategy: | ||
| matrix: | ||
| python-version: ${{ fromJson(inputs.python-version-array) }} | ||
| runs-on: ${{ fromJson(inputs.runs-on-array) }} | ||
| runs-on: ${{ matrix.runs-on }} | ||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/setup-node@v2 | ||
| with: | ||
| node-version: "14.x" | ||
| registry-url: ${{ inputs.node-registry-url }} | ||
| - name: Pin NPM Version | ||
| run: npm install -g [email protected] | ||
| - name: Use Python ${{ matrix.python-version }} | ||
| uses: actions/setup-python@v2 | ||
| with: | ||
| python-version: ${{ matrix.python-version }} | ||
| - name: Install Python Dependencies | ||
| run: pip install hatch poetry | ||
| - name: Run Scripts | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} | ||
| PYPI_USERNAME: ${{ secrets.pypi-username }} | ||
| PYPI_PASSWORD: ${{ secrets.pypi-password }} | ||
| run: hatch run ${{ inputs.hatch-run }} | ||
| hatch: | ||
| name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} | ||
| strategy: | ||
| matrix: | ||
| python-version: ${{ fromJson(inputs.python-version-array) }} | ||
| runs-on: ${{ fromJson(inputs.runs-on-array) }} | ||
| runs-on: ${{ matrix.runs-on }} | ||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/setup-node@v2 | ||
| with: | ||
| node-version: "14.x" | ||
| registry-url: ${{ inputs.node-registry-url }} | ||
| - name: Pin NPM Version | ||
| run: npm install -g [email protected] | ||
| - name: Use Python ${{ matrix.python-version }} | ||
| uses: actions/setup-python@v2 | ||
| with: | ||
| python-version: ${{ matrix.python-version }} | ||
| - name: Install Python Dependencies | ||
| run: pip install hatch poetry | ||
| - name: Run Scripts | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} | ||
| PYPI_USERNAME: ${{ secrets.pypi-username }} | ||
| PYPI_PASSWORD: ${{ secrets.pypi-password }} | ||
| run: hatch run ${{ inputs.hatch-run }} | 
  
    
      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
    
  
  
    
              
  
    
      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
    
  
  
    
              
  
    
      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
    
  
  
    
              
  
    
      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
    
  
  
    
              
  
    
      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,220 @@ | ||
| from __future__ import annotations | ||
| 
     | 
||
| import logging | ||
| from asyncio import Event, Task, create_task, gather | ||
| from typing import Any, Callable, Protocol, TypeVar | ||
| 
     | 
||
| from anyio import Semaphore | ||
| 
     | 
||
| from reactpy.core._thread_local import ThreadLocal | ||
| from reactpy.core.types import ComponentType, Context, ContextProviderType | ||
| 
     | 
||
| T = TypeVar("T") | ||
| 
     | 
||
| 
     | 
||
| class EffectFunc(Protocol): | ||
| async def __call__(self, stop: Event) -> None: | ||
| ... | ||
| 
     | 
||
| 
     | 
||
| logger = logging.getLogger(__name__) | ||
| 
     | 
||
| _HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) | ||
| 
     | 
||
| 
     | 
||
| def current_hook() -> LifeCycleHook: | ||
| """Get the current :class:`LifeCycleHook`""" | ||
| hook_stack = _HOOK_STATE.get() | ||
| if not hook_stack: | ||
| msg = "No life cycle hook is active. Are you rendering in a layout?" | ||
| raise RuntimeError(msg) | ||
| return hook_stack[-1] | ||
| 
     | 
||
| 
     | 
||
| class LifeCycleHook: | ||
                
      
                  rmorshea marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| """Defines the life cycle of a layout component. | ||
                
      
                  rmorshea marked this conversation as resolved.
               
              
                Outdated
          
            Show resolved
            Hide resolved
         | 
||
| Components can request access to their own life cycle events and state through hooks | ||
| while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle | ||
| forward by triggering events and rendering view changes. | ||
| Example: | ||
| If removed from the complexities of a layout, a very simplified full life cycle | ||
| for a single component with no child components would look a bit like this: | ||
| .. testcode:: | ||
| from reactpy.core._life_cycle_hook import LifeCycleHook | ||
| from reactpy.core.hooks import current_hook | ||
| # this function will come from a layout implementation | ||
| schedule_render = lambda: ... | ||
| # --- start life cycle --- | ||
| hook = LifeCycleHook(schedule_render) | ||
| # --- start render cycle --- | ||
| component = ... | ||
| await hook.affect_component_will_render(component) | ||
| try: | ||
| # render the component | ||
| ... | ||
| # the component may access the current hook | ||
| assert current_hook() is hook | ||
| # and save state or add effects | ||
| current_hook().use_state(lambda: ...) | ||
| async def start_effect(): | ||
| ... | ||
| async def stop_effect(): | ||
| ... | ||
| current_hook().add_effect(start_effect, stop_effect) | ||
| finally: | ||
| await hook.affect_component_did_render() | ||
| # This should only be called after the full set of changes associated with a | ||
| # given render have been completed. | ||
| await hook.affect_layout_did_render() | ||
| # Typically an event occurs and a new render is scheduled, thus beginning | ||
| # the render cycle anew. | ||
| hook.schedule_render() | ||
| # --- end render cycle --- | ||
| hook.affect_component_will_unmount() | ||
| del hook | ||
| # --- end render cycle --- | ||
| """ | ||
| 
     | 
||
| __slots__ = ( | ||
| "__weakref__", | ||
| "_context_providers", | ||
| "_current_state_index", | ||
| "_effect_funcs", | ||
| "_effect_stops", | ||
| "_effect_tasks", | ||
| "_render_access", | ||
| "_rendered_atleast_once", | ||
| "_schedule_render_callback", | ||
| "_scheduled_render", | ||
| "_state", | ||
| "component", | ||
| ) | ||
| 
     | 
||
| component: ComponentType | ||
| 
     | 
||
| def __init__( | ||
| self, | ||
| schedule_render: Callable[[], None], | ||
| ) -> None: | ||
| self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} | ||
| self._schedule_render_callback = schedule_render | ||
| self._scheduled_render = False | ||
| self._rendered_atleast_once = False | ||
| self._current_state_index = 0 | ||
| self._state: tuple[Any, ...] = () | ||
| self._effect_funcs: list[EffectFunc] = [] | ||
| self._effect_tasks: list[Task[None]] = [] | ||
| self._effect_stops: list[Event] = [] | ||
| self._render_access = Semaphore(1) # ensure only one render at a time | ||
| 
     | 
||
| def schedule_render(self) -> None: | ||
| if self._scheduled_render: | ||
| return None | ||
| try: | ||
| self._schedule_render_callback() | ||
| except Exception: | ||
| msg = f"Failed to schedule render via {self._schedule_render_callback}" | ||
| logger.exception(msg) | ||
| else: | ||
| self._scheduled_render = True | ||
| 
     | 
||
| def use_state(self, function: Callable[[], T]) -> T: | ||
| if not self._rendered_atleast_once: | ||
| # since we're not initialized yet we're just appending state | ||
| result = function() | ||
| self._state += (result,) | ||
| else: | ||
| # once finalized we iterate over each succesively used piece of state | ||
| result = self._state[self._current_state_index] | ||
| self._current_state_index += 1 | ||
| return result | ||
| 
     | 
||
| def add_effect(self, effect_func: EffectFunc) -> None: | ||
| """Add an effect to this hook | ||
| A task to run the effect is created when the component is done rendering. | ||
| When the component will be unmounted, the event passed to the effect is | ||
| triggered and the task is awaited. The effect should eventually halt after | ||
| the event is triggered. | ||
| """ | ||
| self._effect_funcs.append(effect_func) | ||
| 
     | 
||
| def set_context_provider(self, provider: ContextProviderType[Any]) -> None: | ||
| self._context_providers[provider.type] = provider | ||
| 
     | 
||
| def get_context_provider( | ||
| self, context: Context[T] | ||
| ) -> ContextProviderType[T] | None: | ||
| return self._context_providers.get(context) | ||
| 
     | 
||
| async def affect_component_will_render(self, component: ComponentType) -> None: | ||
| """The component is about to render""" | ||
| await self._render_access.acquire() | ||
| self._scheduled_render = False | ||
| self.component = component | ||
| self.set_current() | ||
| 
     | 
||
| async def affect_component_did_render(self) -> None: | ||
| """The component completed a render""" | ||
| self.unset_current() | ||
| self._rendered_atleast_once = True | ||
| self._current_state_index = 0 | ||
| self._render_access.release() | ||
| del self.component | ||
| 
     | 
||
| async def affect_layout_did_render(self) -> None: | ||
| """The layout completed a render""" | ||
| stop = Event() | ||
| self._effect_stops.append(stop) | ||
| self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) | ||
                
      
                  rmorshea marked this conversation as resolved.
               
          
            Show resolved
            Hide resolved
         | 
||
| self._effect_funcs.clear() | ||
| 
     | 
||
| async def affect_component_will_unmount(self) -> None: | ||
| """The component is about to be removed from the layout""" | ||
| for stop in self._effect_stops: | ||
| stop.set() | ||
| self._effect_stops.clear() | ||
| try: | ||
| await gather(*self._effect_tasks) | ||
| except Exception: | ||
| logger.exception("Error in effect") | ||
| finally: | ||
| self._effect_tasks.clear() | ||
| 
     | 
||
| def set_current(self) -> None: | ||
| """Set this hook as the active hook in this thread | ||
| This method is called by a layout before entering the render method | ||
| of this hook's associated component. | ||
| """ | ||
| hook_stack = _HOOK_STATE.get() | ||
| if hook_stack: | ||
| parent = hook_stack[-1] | ||
| self._context_providers.update(parent._context_providers) | ||
| hook_stack.append(self) | ||
| 
     | 
||
| def unset_current(self) -> None: | ||
| """Unset this hook as the active hook in this thread""" | ||
| if _HOOK_STATE.get().pop() is not self: | ||
| raise RuntimeError("Hook stack is in an invalid state") # nocov | ||
      
      Oops, something went wrong.
        
    
  
  Add this suggestion to a batch that can be applied as a single commit.
  This suggestion is invalid because no changes were made to the code.
  Suggestions cannot be applied while the pull request is closed.
  Suggestions cannot be applied while viewing a subset of changes.
  Only one suggestion per line can be applied in a batch.
  Add this suggestion to a batch that can be applied as a single commit.
  Applying suggestions on deleted lines is not supported.
  You must change the existing code in this line in order to create a valid suggestion.
  Outdated suggestions cannot be applied.
  This suggestion has been applied or marked resolved.
  Suggestions cannot be applied from pending reviews.
  Suggestions cannot be applied on multi-line comments.
  Suggestions cannot be applied while the pull request is queued to merge.
  Suggestion cannot be applied right now. Please check back later.
  
    
  
    
Uh oh!
There was an error while loading. Please reload this page.