import { createContext, useContext, useMemo, useState } from 'react' import { featureProviders } from './registry.js' import { buildFeatureGate, OPEN_GATE } from './featureGate.js' // The React half of the feature seam. The decision logic is featureGate.js, // which is plain JS and therefore testable in a runner with no DOM; this file is // wiring, the same split registry.js and shared.js already use. // // **Calling a hook per provider inside a loop is the point, and it is legal // here.** The rules of hooks require the same hooks in the same order on every // render of a component — not a statically known list. The provider list is // fixed before the first render (registration happens while module chunks // evaluate, and main.jsx does not mount until DOMContentLoaded), there is no // unregistering, and the snapshot below freezes it per component instance // anyway. So the loop's length cannot change between renders of this provider, // which is the actual requirement. // // A provider hook returns a Set-like of the flags this viewer may see, or `null` // while it is still fetching. Core knows nothing else about it: what a flag // means, how it is fetched, and what it is gated on are all the module's. const FeatureGateContext = createContext(OPEN_GATE) export function ModuleFeaturesProvider({ children }) { // Snapshotted once. useState's initialiser runs on the first render only, so // even a provider that somehow registered late cannot change this instance's // hook count mid-life — it would be ignored until the next mount, which is a // far better failure than a crashed render. const [providers] = useState(featureProviders) // eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above const values = providers.map((provider) => provider.hook()) const gate = useMemo( () => { const byOwner = new Map() // First registration wins for a given owner: a module that registers two // namespaces answers its own nav rows from the first, rather than from // whichever happened to be stored last. providers.forEach((provider, i) => { if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i]) }) return buildFeatureGate(byOwner) }, // One dependency per provider — a fixed-length list, for the same reason the // hook loop above is fixed-length. // eslint-disable-next-line react-hooks/exhaustive-deps [providers, ...values], ) return {children} } /** * The predicate to filter nav rows with: `(item) => boolean`, true when the row * carries no `feature` or when its module says this viewer may see it. * * Outside a provider it is the open gate, so a component rendered in isolation * (a test, a preview) shows its whole nav rather than none of it. */ export function useFeatureGate() { return useContext(FeatureGateContext) } export default ModuleFeaturesProvider