Skip to content

fix(core): stop useTexture's array/record forms looping forever - #3858

Open
DennisSmolek wants to merge 1 commit into
v10from
fix/3849-usetexture-loop
Open

fix(core): stop useTexture's array/record forms looping forever#3858
DennisSmolek wants to merge 1 commit into
v10from
fix/3849-usetexture-loop

Conversation

@DennisSmolek

Copy link
Copy Markdown
Member

Fixes #3849.

useTexture with an array or record argument re-ran its registry effect on every render, and the store.setState that effect performs re-renders every store subscriber, so the two fed each other until React bailed out with Maximum update depth exceeded. The single-URL form was unaffected.

Two instabilities, both load-bearing

Fixing either one alone still loops — I verified this by reverting each half independently.

1. useLoader returned a new array every call. .map() builds a fresh array each render, so the result could never be used as an effect dependency — by anyone. This is the root cause and it isn't useTexture-specific; useTexture's registry effect was just the first place it bit.

The array is now shallow-compared and its identity preserved while the contents are unchanged. Shallow-compared rather than keyed on the cache keys — that alternative looks simpler but silently breaks invalidation, because useLoader.clear() can hand back new objects for the same keys and that change still has to propagate. Comparing resolved values means a genuine change always yields a new identity and an incidental re-render never does.

2. useTexture keyed its memos and effect on the raw input reference. Callers routinely pass an inline literal, so that reference is new every render too. Everything now keys off a value signature plus an identity-stable view of the input.

The signature includes the record's keys, not just its URLs — two records can share URLs while mapping them to different names, and those aren't interchangeable.

On the render-phase ref write in useLoader

useLoader previously used no React hooks. It now uses one useRef. The write during render is idempotent — given equal contents it always settles on the same array — so it's safe under StrictMode's double render. Flagging it since it's the least conventional part of this change.

Testing

Three regression tests, all confirmed failing on pre-fix source with the reported error:

× array form settles instead of looping forever
    Error: Maximum update depth exceeded.
× record form settles instead of looping forever
    Error: Maximum update depth exceeded.
× array form returns a reference-stable result across re-renders
    AssertionError: expected [ …(2) ] to be [ …(2) ]

Worth knowing if you edit these: the useThree() call inside each Consumer is load-bearing. The loop only closes when the component calling useTexture is itself a broad store subscriber — a sibling subscriber re-renders itself, not the useTexture caller. My first draft put it in a sibling and the tests passed on the broken build. The issue's own repro has it in the same component; that detail matters.

  • pnpm test ✅ 584 passed (was 581), 45 files
  • pnpm typecheck / pnpm eslint / pnpm format

🤖 Generated with Claude Code

useTexture with an array or record argument re-ran its registry effect on every
render, and the store.setState that effect performs re-renders every store
subscriber, so the two fed each other until React bailed out with "Maximum
update depth exceeded". The single-URL form was unaffected.

Two independent instabilities had to go, and fixing either alone still loops:

useLoader built its result array with .map() on every call, so the returned
array had a new identity every render. That is the root cause and it is not
useTexture-specific -- any consumer using the result as an effect dependency
hits it. The array is now shallow-compared and its identity preserved while the
contents are unchanged. Shallow-compared rather than keyed on the cache keys,
because useLoader.clear() can hand back new objects for the same keys and that
invalidation still has to propagate.

useTexture keyed its memos and its registry effect on the raw `input`
reference. Callers routinely pass an inline literal, so that reference is new
every render too. Everything now keys off a value signature (which includes the
record's keys -- two records can share URLs but map them to different names)
and an identity-stable view of the input.

Tests reproduce the reported failure exactly: the broad `useThree()`
subscription lives in the same component as useTexture, because a sibling
subscriber re-renders itself and not the useTexture caller, and without that the
tests pass even on the broken build.

Fixes #3849

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant