fix(bindx-react): keep the hook count fixed and subscribe DSL conditions - #78
Closed
matej21 wants to merge 1 commit into
Closed
fix(bindx-react): keep the hook count fixed and subscribe DSL conditions#78matej21 wants to merge 1 commit into
matej21 wants to merge 1 commit into
Conversation
<Switch> called useField inside a loop over its <Case> children, behind a rules-of-hooks eslint-disable and a comment asserting a stable count that nothing enforced. SwitchProps.children is ReactNode, so a conditionally rendered <Case> type-checks and then crashes React with "Rendered more hooks than during the previous render". Separately, a cond.* DSL condition subscribed to nothing: If and Case passed null to useField whenever the condition was a Condition object rather than a bare FieldRef, while evaluateCondition kept reading the values live. Inside a memoized subtree, where the parent's re-render does not reach the child, the branch never re-evaluated. Both are the same missing capability — subscribing to a number of refs that is not known at build time. useFields(refs) takes a single useSyncExternalStore subscription over N refs, so the hook count stays constant, and <Switch> and <If> now feed it their condition fields. It widens ref to accessor through overloads exactly as useAccessor already does, so no cast is involved. The repo has no eslint config, so the rules-of-hooks disable removed here was never enforced by anything — which is how the crash survived.
This was referenced Aug 20, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Two defects in the same area, both closed by one missing capability.
1.
<Switch>crashes on a conditionally rendered<Case>SwitchImplcalleduseFieldinside a loop over its<Case>children:The disable carried a comment asserting a "stable count, stable order". Nothing enforced that —
SwitchProps.childrenisReactNode, so this type-checks:and then:
A hard crash, not a degradation.
2.
cond.*DSL conditions subscribed to nothingWhen the condition handed to
<If>or<Case if>was aConditionobject rather than a bareFieldRef, both components passednulltouseField— no subscription at all — whileevaluateConditionwent on reading the values live.This is invisible most of the time, because the parent recreates the branch JSX on every render and busts
memo. It bites inside a memoized subtree, where the parent's re-render never reaches the child. That is precisely the invariantuseEntity.ts:326-329documents ("memoized children re-render through their own subscription") — the DSL path was the one condition form with no subscription to re-render through.The regression test pins the structural precondition rather than assuming it: it asserts the memo probe's render count did not increase, that a plain
<Field>in the same subtree did update, and only then that the branch flipped.The fix
Both are the same gap — subscribing to a number of refs not known at build time.
useFields(refs)takes oneuseSyncExternalStoresubscription covering N refs: it readsFIELD_REF_METAoff anything that has it, ignores nulls and non-refs, dedupes toentityType:entityId, and subscribes to all of them from a singlesubscribecallback. The hook count no longer depends on the data.<Switch>now calls exactly two hooks regardless of case count. The loop and the eslint-disable are gone.<If>keeps its singleuseFieldand adds one unconditionaluseFields(collectConditionFields(condition)).Ref→accessor widening goes through overloads, the same way
useAccessoralready does it — no cast, noany.Verification
bun test tests/react/jsx/— 74 pass / 0 failSwitch.test.tsxandIf.test.tsxare unmodified and green, so existing behaviour (first match wins,showvsif, callback children, the<Default>errors) is preserved without any assertion being touchedReceived: nullfor both componentsWorth knowing
The repo has no eslint config at all. Every
eslint-disablein the tree is decorative, which is how a rules-of-hooks violation shipped. Wiring upeslint-plugin-react-hookswould be worth a follow-up —componentFactory.ts:80still has the same shape (stable count today, latent tomorrow), andcomponentFactory.ts:156has the same missing-subscription bug as #2 above forcreateComponent().if().