Skip to content

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

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

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

Conversation

@rkaraivanov

@rkaraivanov rkaraivanov commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

Fix render prop state across renders

Render prop state lived in per-render locals, while the element kept
its patched templates indefinitely. The two drifted apart on every
re-render:

  • Stale closures. A template reading React state kept the callback
    it saw first.
  • undefined render props rendered blank instead of the element's
    default.
  • A render prop added later evicted the others: requests updated a
    stale copy of the slot map.
  • Nested config objects, like igc-chat's options, changed
    identity every render, so the element re-rendered with them.

A TemplateBridge per component instance now owns that state.
Templates keep their identity and close over nothing but the
renderer name. Element requests only record slots; render fills them
from the current props.

Async render props are resolved by the bridge, which keeps the
previous content until the new one settles. Suspending on them would
never settle, since inline render props yield a new promise per
render. Rejections rethrow on render for error boundaries; the
nearest <Suspense> no longer sees them.

WithJsxRenderProps recurses into nested renderer maps and keeps
optional render props optional.

Also fixes:

  • equal let failed Set/Map probes mark objects as visited, so
    collections with different contents compared equal. It now tracks
    pairs, matches entries one to one, and handles null-prototype
    objects.
  • Templates invoked without a context threw: withDataContext
    proxied non-objects.

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

github-code-quality Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Code Coverage Overview

Languages: TypeScript

TypeScript / React Wrappers

The overall line coverage in commit 2a17c86 in the rkaraivanov/react-te... branch is 97%. The line coverage in commit 7aed5de in the master branch is 96%.

Show a line coverage summary of the most impacted files.
File master 7aed5de rkaraivanov/react-te... 2a17c86 +/-
src/backfills.ts 100% 100% 0%
src/equal.ts 100% 100% 0%
src/react-props.tsx 94% 97% +3%
src/render-props.ts 88% 91% +3%
src/is-object.ts 0% 100% +100%

Updated September 28, 2026 16:04 UTC

rkaraivanov and others added 5 commits September 17, 2026 09:39
Render prop state lived in per-render locals, while the element kept
its patched templates indefinitely. The two drifted apart on every
re-render:

* Stale closures. A template reading React state kept the callback
  it saw first.
* `undefined` render props rendered blank instead of the element's
  default.
* A render prop added later evicted the others: requests updated a
  stale copy of the slot map.
* Nested config objects, like `igc-chat`'s `options`, changed
  identity every render, so the element re-rendered with them.

A `TemplateBridge` per component instance now owns that state.
Templates keep their identity and close over nothing but the
renderer name. Element requests only record slots; render fills them
from the current props.

Async render props are resolved by the bridge, which keeps the
previous content until the new one settles. Suspending on them would
never settle, since inline render props yield a new promise per
render. Rejections rethrow on render for error boundaries; the
nearest `<Suspense>` no longer sees them.

`WithJsxRenderProps` recurses into nested renderer maps and keeps
optional render props optional.

Also fixes:

* `equal` let failed Set/Map probes mark objects as visited, so
  collections with different contents compared equal. It now tracks
  pairs, matches entries one to one, and handles null-prototype
  objects.
* Templates invoked without a context threw: `withDataContext`
  proxied non-objects.

This branch has not been deployed

No deployments
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.

2 participants