Skip to content

fix(render-props): keep render prop state alive across renders - #193

Open
rkaraivanov wants to merge 2 commits into
masterfrom
rkaraivanov/react-templates-fixes
Open

fix(render-props): keep render prop state alive across renders#193
rkaraivanov wants to merge 2 commits into
masterfrom
rkaraivanov/react-templates-fixes

Conversation

@rkaraivanov

Copy link
Copy Markdown
Member

The render prop machinery kept its state in per-render locals while the
element held on to the patched templates indefinitely, so the two drifted
apart the moment the component re-rendered. Three defects came out of that:

  • Templates rendered against a stale closure. The patched function handed
    to the element was cached on first sight - but so was the callback it
    invoked, so a template closing over React state kept rendering the value
    it saw on the very first render.

  • An undefined render prop spun the render loop. A conditional prop
    (headerTemplate={enabled ? tpl : undefined}) was still treated as a
    template: it installed an empty portal and recreated the patched function
    on every render. Each new identity re-rendered the element, which
    re-requested the template, at roughly 97 cycles a second. The churn also
    blanked sibling templates on the same component.

  • Portal updates were lost. The map of active slots was cloned per render,
    so a request arriving from the element mutated its own copy and a later
    render overwrote it.

That state now lives on a TemplateBridge owned by the component instance -
patched templates keyed by prop path, current callbacks keyed by renderer
name, the slots, and the nested prop containers. Callbacks are refreshed on
every render while the patched templates keep their identity, so the element
sees a stable prop and the template always runs the current closure. Portals
are built when the element requests a slot, and rebuilt only when the render
prop's identity changes.

Additional information (check all that apply):

  • Bug fix

Checklist:

  • All relevant tags have been applied to this PR

The render prop machinery kept its state in per-render locals while the
element held on to the patched templates indefinitely, so the two drifted
apart the moment the component re-rendered. Three defects came out of that:

* Templates rendered against a stale closure. The patched function handed
  to the element was cached on first sight - but so was the callback it
  invoked, so a template closing over React state kept rendering the value
  it saw on the very first render.

* An `undefined` render prop spun the render loop. A conditional prop
  (`headerTemplate={enabled ? tpl : undefined}`) was still treated as a
  template: it installed an empty portal and recreated the patched function
  on every render. Each new identity re-rendered the element, which
  re-requested the template, at roughly 97 cycles a second. The churn also
  blanked sibling templates on the same component.

* Portal updates were lost. The map of active slots was cloned per render,
  so a request arriving from the element mutated its own copy and a later
  render overwrote it.

That state now lives on a `TemplateBridge` owned by the component instance -
patched templates keyed by prop path, current callbacks keyed by renderer
name, the slots, and the nested prop containers. Callbacks are refreshed on
every render while the patched templates keep their identity, so the element
sees a stable prop and the template always runs the current closure. Portals
are built when the element requests a slot, and rebuilt only when the render
prop's identity changes.

Pruning is driven by a single predicate - is there still a function at this
prop path? - which covers both a removed prop and one that turned
`undefined`. It also drops the matching slots: the directive's remove request
travels through a `WeakRef` and may never arrive once the patched template is
gone, leaving the portal to linger for the component's lifetime.

Nested prop containers reuse their previous object while shallow-equal, so a
config object carrying a template stops changing identity every render.

The rest of the file follows the same split. Ref forwarding and the Angular
re-parenting effect move into `useForwardedRef` and `useReparenting`, leaving
`createComponent` with registration and a nine-line component.

In `render-props.ts`: `_renderNode` could return `undefined` behind an
`as Element` cast; requests now go through a helper that bails when there is
no node. `render()` is typed as `RendererCallback<T>` so `any` stops leaking
into `update()`. `_state.previous` resets on every disconnect, not just some.

Adds regression tests for the three defects, each confirmed to fail against
the previous code.
@github-code-quality

Copy link
Copy Markdown

Code Coverage Overview

Languages: TypeScript

TypeScript / React Wrappers

The overall coverage in commit 48069f5 in the rkaraivanov/react-te... branch is 95%. The coverage in commit d835699 in the master branch is 97%.

Show a code coverage summary of the most impacted files.
File master d835699 rkaraivanov/react-te... 48069f5 +/-
src/react-props.tsx 94% 91% -3%
src/render-props.ts 97% 97% 0%
src/equal.ts 100% 100% 0%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant