@evanion/react-feature 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## 0.1.0 (2026-10-17)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **react-feature:** re-export the core types its own declarations name ([4a4fef01](https://github.com/Evanion/libraries/commit/4a4fef01))
6
+ - **react-feature:** extract the react adapter into its own package ([af19e96a](https://github.com/Evanion/libraries/commit/af19e96a))
7
+
8
+ ### 🩹 Fixes
9
+
10
+ - **react-feature:** hold the binding back while the core is private ([bce9e235](https://github.com/Evanion/libraries/commit/bce9e235))
11
+
12
+ ### 💅 Refactors
13
+
14
+ - **react-feature:** call adoptDecisions from the provider ([0bd40d25](https://github.com/Evanion/libraries/commit/0bd40d25))
15
+
16
+ ### 🧱 Updated Dependencies
17
+
18
+ - Updated @evanion/feature to 0.1.0
19
+
20
+ ### ❤️ Thank You
21
+
22
+ - Mikael Pettersson @Evanion
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Mikael Pettersson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @evanion/react-feature
2
+
3
+ **Feature decisions for your React component tree, resolved once and read anywhere.**
4
+
5
+ To switch a single banner in a complex UI, you often have to prop-drill a flags object through ten layers of components. `@evanion/react-feature` solves this by publishing the decisions `@evanion/feature` has already resolved. Any component in the tree can ask "is this on, and which variant am I in?" without needing to know where the store or the evaluation context lives.
6
+
7
+ ## The Problem: The "Flags Prop-Drill"
8
+
9
+ When a server resolves flags for a request, the component that actually needs a specific flag is often buried deep in the hierarchy. This creates two primary frictions:
10
+
11
+ 1. **Prop-Drilling**: Components that do not use any flags are forced to carry the flags object simply because a distant child requires it.
12
+ 2. **Two Answers Per Request**: If a component resolves a flag for itself, it reads its own clock. This means two components in a single render could land on opposite sides of a time window, causing the page to contradict itself.
13
+
14
+ ## The Solution: One Resolution, Published
15
+
16
+ `@evanion/feature` resolves every configured feature in one call, and `features.snapshot` states that result as a `DecisionSet` containing the instant of resolution, one decision per key, and the config version wherever the store was built from a document that carries one.
17
+
18
+ `FeatureProvider` publishes that set to the React tree, allowing `useFeature`, `useFeatureEnabled`, `useVariant`, and `useFeatures` to read it. Because the decisions are already made, no hook evaluates anything during the render.
19
+
20
+ ### Core Concept: The Server Resolves, The Tree Reads
21
+
22
+ To avoid duplicating complex logic in the UI, `@evanion/react-feature` separates the act of deciding which feature to show from the act of reading that decision.
23
+
24
+ Consider a game shop that splits shoppers between two different shelf-sorting methods. Instead of the UI calculating the split, the server resolves it for a specific shopper via `features.snapshot`, and `FeatureProvider` publishes that set. The `Shelf` component then simply reads its assigned variant. If Sam buckets into `popular` and Mika into `newest`, neither component has to perform the resolution logic themselves.
25
+
26
+ To ensure consistency, the provider takes the shopper's targeting context alongside the set. It publishes the shipped decisions either way, and it re-resolves them against that context to check them; consequently, a provider handed no `targetingKey` reports every weighted assignment as one it cannot reproduce. The context the server resolved with is the context the provider needs.
27
+
28
+ The following block runs in the package's test suite, where each `// ->` comment denotes the expression's value.
29
+
30
+ <!-- #region server-render -->
31
+
32
+ ```tsx @import.meta.vitest
33
+ /** @jsxRuntime classic */
34
+ import * as React from 'react';
35
+ // ---cut---
36
+ import { createFeatures } from '@evanion/feature';
37
+ import type { DecisionSet } from '@evanion/feature';
38
+ import { FeatureProvider, useVariant } from '@evanion/react-feature';
39
+ import { useMemo } from 'react';
40
+ import type { ReactNode } from 'react';
41
+ import { renderToStaticMarkup } from 'react-dom/server';
42
+
43
+ // app/features.ts, imported by the server and by the client bundle. A store
44
+ // carries functions, so it reaches a client module as an import and never as a
45
+ // prop.
46
+ const features = createFeatures([
47
+ {
48
+ key: 'shelf-sort',
49
+ enabled: true,
50
+ variants: [
51
+ { name: 'newest', weight: 50 },
52
+ { name: 'popular', weight: 50 },
53
+ ],
54
+ },
55
+ ]);
56
+
57
+ type ShopDecisions = DecisionSet<{
58
+ 'shelf-sort': { variant: 'newest' | 'popular' };
59
+ }>;
60
+
61
+ // app/shelf.tsx, a client module ('use client')
62
+ function Shelf() {
63
+ const { variant } = useVariant('shelf-sort');
64
+
65
+ // A feature that resolved off carries no variant, so the shop's own order
66
+ // answers that case and neither arm of the split does.
67
+ if (variant === undefined) return <p>Shop order</p>;
68
+
69
+ return <p>{variant === 'popular' ? 'Most played first' : 'Newest first'}</p>;
70
+ }
71
+
72
+ // app/flags.tsx, a client module ('use client')
73
+ function ShopFlags({
74
+ shopperId,
75
+ decisions,
76
+ children,
77
+ }: {
78
+ shopperId: string;
79
+ decisions: ShopDecisions;
80
+ children: ReactNode;
81
+ }) {
82
+ // The provider re-resolves the shipped set to check it, so it needs the
83
+ // field the server bucketed on. A provider mounted without `targetingKey`
84
+ // publishes the shipped variant and reports it as one it cannot reproduce.
85
+ // `useMemo` holds the object across renders, which the provider's own memo
86
+ // reads as the same context.
87
+ const context = useMemo(() => ({ targetingKey: shopperId }), [shopperId]);
88
+
89
+ return (
90
+ <FeatureProvider
91
+ features={features}
92
+ context={context}
93
+ decisions={decisions}
94
+ >
95
+ {children}
96
+ </FeatureProvider>
97
+ );
98
+ }
99
+
100
+ // app/page.tsx, on the server, per request
101
+ const shelfFor = (shopperId: string) => {
102
+ const decisions = features.snapshot({ targetingKey: shopperId });
103
+
104
+ // The framework serialises this prop on its way into the client module.
105
+ // React's flight serializer and Astro's `devalue` carry the `Date` a window
106
+ // condition's `failed.value` can hold, and `JSON.stringify` by hand sends a
107
+ // string in its place.
108
+ return renderToStaticMarkup(
109
+ <ShopFlags shopperId={shopperId} decisions={decisions}>
110
+ <Shelf />
111
+ </ShopFlags>,
112
+ );
113
+ };
114
+
115
+ shelfFor('sam'); // -> '<p>Most played first</p>'
116
+ shelfFor('mika'); // -> '<p>Newest first</p>'
117
+ ```
118
+
119
+ <!-- #endregion server-render -->
120
+
121
+ ## Key Features
122
+
123
+ `@evanion/react-feature` provides the following capabilities:
124
+
125
+ - 🧩 **Zero Prop-Drilling**: Components name the key they care about and read the decision from the provider above them.
126
+ - 🕓 **One Instant Per Render**: A set a render resolved states the instant it was created. The provider reads that instant as the default for anything it resolves itself, ensuring no two components in one tree straddle a time window.
127
+ - ⚡ **Local Reads**: Hooks read decisions already held by the provider, so variant switches never wait on network requests.
128
+ - 🛠️ **Type-Safety**: `createFeatureContext(features)` returns a provider and hooks bound to one store's schema. Misspelled keys trigger compile errors, and variants are constrained to the union declared by the store.
129
+ - 🧬 **Divergence Reports**: When handed a shipped set, a provider compares the config version against the store. It names decisions it cannot reproduce via `onDivergence`. Where the set and the store each carry a config version and the two differ, `onVersionMismatch` decides whether the tree renders the shipped set or a local re-resolution. A store built from a bare definitions array carries no version, so a provider over one reports `unversioned` and keeps the shipped set whatever `onVersionMismatch` asks for. Build both sides from a document that carries a version to make the choice available.
130
+ - 📦 **Lightweight Footprint**: The package is ESM only and requires Node 20 or newer. Its only peer dependency is React 18 or 19, with `@evanion/feature` pinned as a dependency.
131
+
132
+ ## Installation
133
+
134
+ Note: This package is not on npm yet. Because `package.json` carries `private: true`, `npm install @evanion/react-feature` does not resolve until the first version is published.
135
+
136
+ ```bash
137
+ npm install @evanion/react-feature @evanion/feature
138
+ ```
139
+
140
+ ## Documentation
141
+
142
+ To master the implementation and explore worked examples, visit the full documentation site:
143
+
144
+ 👉 **[docs.evanion.com/react-feature](https://docs.evanion.com/react-feature/)**
145
+
146
+ The `@evanion/react-feature` hooks operate by reading a shared model of features, rules, variants, and decision sets. Because this model is defined in the core package, its detailed documentation is located here:
147
+
148
+ 👉 **[docs.evanion.com/feature](https://docs.evanion.com/feature/)**
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether this bundle is a production one.
3
+ *
4
+ * `process` is a Node global. This package ships as plain ESM with no bundling
5
+ * step and no shim, so a browser loading `@evanion/react-feature` through an
6
+ * import map holds no `process` and reading one raises a `ReferenceError`, and
7
+ * a shim that supplies `process` without `env` raises a `TypeError`. Either
8
+ * one raises out of the provider's `useMemo`, so the whole tree fails to
9
+ * mount over a question about a bundle flag.
10
+ *
11
+ * The member chain is written out whole because a bundler's `define` matches
12
+ * the text `process.env.NODE_ENV` and substitutes the string. A guard that
13
+ * read `process.env` into a local first would defeat that substitution and
14
+ * leave a production bundle running the development diff. `try` keeps the text
15
+ * a bundler replaces and still answers for a runtime that defines neither
16
+ * half.
17
+ *
18
+ * A runtime that answers neither half answers `true`, because this read gates
19
+ * work and not a message. § 4 requires the development diff to stay out of
20
+ * production, and the runtime that cannot be read is a plain ESM deployment,
21
+ * which is as often a served production page as a developer's machine. The
22
+ * diff costs a second `features.resolve` per mount, a second `'resolve'` event
23
+ * on a store built with `observe`, and a `'decision-differs'` report for every
24
+ * key an observer then has to triage. A developer who wants the diff in a
25
+ * runtime with no `process` supplies one: `globalThis.process = { env: { NODE_ENV: 'development' } }`
26
+ * ahead of the provider's first render answers the read.
27
+ *
28
+ * `createEmitter` takes the other side for the same read
29
+ * (`observe.ts:290-294`), and it gates a `console.warn` that costs a line of
30
+ * output.
31
+ */
32
+ export declare function production(): boolean;
33
+ //# sourceMappingURL=hydrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hydrate.d.ts","sourceRoot":"","sources":["../src/hydrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAMpC"}
@@ -0,0 +1,254 @@
1
+ import { ReactElement, ReactNode } from 'react';
2
+ import { Decision, Decisions, DecisionSet, DivergenceObserver, EvaluationContext, FeatureKey, Features, FrozenWhenObserved, Schema, VariantInfo, VersionMismatchPolicy } from '@evanion/feature';
3
+ /**
4
+ * The document types, re-exported so a component file imports one specifier.
5
+ *
6
+ * A React application that holds a store and reloads it from a poller writes
7
+ * the handler beside the component, and that file names `ReloadResult` and
8
+ * `FeatureConfig`. Types only: `serializeConfig`, `configDigest`,
9
+ * `validateConfig` and `parseFeatureConfig` stay on the core entry, because a
10
+ * component calling one of them holds configuration in a render tree and
11
+ * reaches for the core.
12
+ */
13
+ export type { BaseFieldType, ConfigEnvelope, ConfigIssue, ConfigIssueCode, ContextSchema, FeatureConfig, FeatureSchema, FeatureShape, FieldType, JsonValue, ReloadResult, SerializedAttributeCondition, SerializedCondition, SerializedDefinition, SerializedInstant, SerializedRule, SerializedVariantSpec, SerializedWindowCondition, ValidationResult, ValueShape, } from '@evanion/feature';
14
+ /**
15
+ * The core types this package's own public declarations name, re-exported for
16
+ * the reason the document types above are: one specifier per component file.
17
+ *
18
+ * `FeatureProviderProps` names `Features`, `EvaluationContext` and
19
+ * `DecisionSet`. `BoundFeatureProviderProps` names `Features`. `BoundVariant`
20
+ * names `Decisions`. `FeatureContext` names `Decisions`, `FeatureKey` and
21
+ * `FrozenWhenObserved`. {@link AnyDecisions} names `Decision` and
22
+ * `FeatureKey`. `Schema` is the default `FeatureProviderProps` gives its
23
+ * schema parameter, and `VariantInfo` is the constraint every one of those
24
+ * declarations writes. A reader who annotates one prop reached two specifiers
25
+ * before this clause: `@evanion/react-feature` for the prop type and
26
+ * `@evanion/feature` for the type inside it.
27
+ *
28
+ * Types only. The core's values stay on the core entry, which
29
+ * `entry-surface.spec.tsx` holds this package's module namespace to.
30
+ */
31
+ export type { Decision, Decisions, EvaluationContext, FeatureKey, Features, FrozenWhenObserved, Schema, VariantInfo, } from '@evanion/feature';
32
+ /**
33
+ * What a provider publishes, at the erased types.
34
+ *
35
+ * Every `Decision<'cta', 'control' | 'blue', { label: string }>` assigns to
36
+ * `Decision<FeatureKey>` because `key`, `variant`, `value`, `blockedBy` and
37
+ * `cause` all sit in output position. A React context fixes its type when the
38
+ * context is created and cannot hold the schema a provider was given, so a
39
+ * provider widens to this once and the bound hooks narrow back.
40
+ *
41
+ * `Decision<FeatureKey>` and not `Decision`: the parameter defaults to `string`
42
+ * while its constraint admits a number, so a store keyed on a numeric enum
43
+ * produces a `Decision<7>` whose `key` does not assign to `Decision`'s. The
44
+ * widening only holds at the constraint.
45
+ */
46
+ export type AnyDecisions = Readonly<Record<FeatureKey, Decision<FeatureKey>>>;
47
+ export type { VersionMismatchPolicy } from '@evanion/feature';
48
+ export type { DecisionOrigin, DecisionSet } from '@evanion/feature';
49
+ export type { DivergenceObserver, DivergenceReport, DivergenceSide, } from '@evanion/feature';
50
+ /**
51
+ * What {@link FeatureProvider} takes.
52
+ *
53
+ * `features` is required, because this provider holds no store of its own.
54
+ * {@link createFeatureContext} is the form that already holds one, and
55
+ * {@link BoundFeatureProviderProps} is what its provider takes.
56
+ *
57
+ * `S` is the store's schema, and it types `decisions` and `onDivergence` on
58
+ * the keys that store declares, so a set another store resolved is a compile
59
+ * error at the mount. The hooks underneath read {@link AnyDecisions} whatever
60
+ * `S` is, which is the erasure a React context imposes and the reason the
61
+ * factory exists.
62
+ */
63
+ export interface FeatureProviderProps<S extends Record<keyof S, VariantInfo | never> = Schema> {
64
+ features: Features<S>;
65
+ /**
66
+ * The evaluation context. Resolution is memoised on this object's identity, so
67
+ * pass a stable reference -- an object literal written inline re-resolves on
68
+ * every render.
69
+ *
70
+ * Leaving `now` out means "the clock at the moment this context was first
71
+ * resolved". A component tree that must agree with a server render should pass
72
+ * `now` explicitly.
73
+ */
74
+ context?: EvaluationContext;
75
+ /**
76
+ * Decisions resolved elsewhere, as the versioned set `Features.snapshot`
77
+ * produces. A server render hands the client the set it resolved, and this
78
+ * provider publishes it.
79
+ *
80
+ * The set states the config version it was resolved under and the instant it
81
+ * was resolved at, so this provider can tell whether the two processes hold
82
+ * one configuration and can resolve the remainder at the server's instant.
83
+ */
84
+ decisions?: DecisionSet<S>;
85
+ /** Notified for every divergence this provider detects. Alters no decision. */
86
+ onDivergence?: DivergenceObserver<keyof S & FeatureKey>;
87
+ /**
88
+ * What to render when the shipped version is not the store's. Defaults to
89
+ * `'use-shipped'`.
90
+ *
91
+ * An application that runs no experiment and cares about revocation latency
92
+ * passes `'re-resolve'`.
93
+ */
94
+ onVersionMismatch?: VersionMismatchPolicy;
95
+ children?: ReactNode;
96
+ }
97
+ /**
98
+ * Publishes one store's decisions to every component under it.
99
+ *
100
+ * With `decisions` left out the provider resolves `features` against
101
+ * `context`, once per identity of the two. With a set supplied it publishes
102
+ * that set: the server rendered HTML from those decisions and counted an
103
+ * exposure for each variant it assigned, so a client answering differently
104
+ * makes an experiment result wrong by an amount no analysis recovers. A
105
+ * version mismatch renders the shipped set unless `onVersionMismatch` names
106
+ * `'re-resolve'`.
107
+ *
108
+ * Decides nothing itself. `@evanion/feature` resolves, and this component
109
+ * writes the answer into React context and renders `children`.
110
+ *
111
+ * Every hook this package exports throws outside a provider, and the message
112
+ * names the provider and the package. {@link useFeature},
113
+ * {@link useFeatureEnabled} and {@link useVariant} throw for a key the
114
+ * published decisions do not carry.
115
+ */
116
+ export declare function FeatureProvider<S extends Record<keyof S, VariantInfo | never>>({ features, context, decisions, onDivergence, onVersionMismatch, children, }: FeatureProviderProps<S>): import('react').JSX.Element;
117
+ /** Every resolved decision, at the erased types. */
118
+ export declare function useFeatures(): AnyDecisions;
119
+ /**
120
+ * One feature's decision, explanation included.
121
+ *
122
+ * Throws for a key that is not configured. {@link createFeatureContext} binds
123
+ * this hook to one store's keys, so a component under a bound provider names a
124
+ * key the compiler checks.
125
+ */
126
+ export declare function useFeature(key: FeatureKey): Decision<FeatureKey>;
127
+ /** One feature's decision as a boolean. */
128
+ export declare function useFeatureEnabled(key: FeatureKey): boolean;
129
+ /**
130
+ * One feature's assigned variant and its value.
131
+ *
132
+ * A reader over the decision `useFeature` already returns. Both are absent for
133
+ * a feature that resolved off and for a feature declaring no variants, so
134
+ * calling code handles off before it switches on a name.
135
+ *
136
+ * Throws for a key the provider does not carry, which `useFeature` does for the
137
+ * same reason: a silent `undefined` would make a typo indistinguishable from a
138
+ * feature nobody assigned.
139
+ */
140
+ export declare function useVariant(key: FeatureKey): Pick<Decision, 'variant' | 'value'>;
141
+ /**
142
+ * Props of the provider {@link createFeatureContext} returns.
143
+ *
144
+ * `features` is optional here and required on {@link FeatureProvider}: the
145
+ * factory already holds a store, and a mount that supplies the context alone is
146
+ * the common one. Pass it to resolve a different store with the same schema --
147
+ * a per-tenant configuration, or the copy a browser rebuilt from JSON.
148
+ *
149
+ * `Frozen` comes from the factory's own store, and a substituted store carries
150
+ * the same parameter. The hooks type their decisions off the factory's answer,
151
+ * so a store that freezes what it resolves under a factory built from a store
152
+ * that freezes nothing would publish frozen decisions to hooks promising the
153
+ * mutable form. The parameter here turns that mount into a compile error.
154
+ */
155
+ export interface BoundFeatureProviderProps<S extends Record<keyof S, VariantInfo | never>, Frozen extends boolean = boolean> {
156
+ features?: Features<S, Frozen>;
157
+ context?: EvaluationContext;
158
+ /**
159
+ * Decisions resolved elsewhere, as the versioned set `Features.snapshot`
160
+ * produces. A server render hands the client the set it resolved, and this
161
+ * provider publishes it.
162
+ */
163
+ decisions?: DecisionSet<S, Frozen>;
164
+ /** Notified for every divergence this provider detects. Alters no decision. */
165
+ onDivergence?: DivergenceObserver<keyof S & FeatureKey>;
166
+ /**
167
+ * What to render when the shipped version is not the store's. Defaults to
168
+ * `'use-shipped'`.
169
+ */
170
+ onVersionMismatch?: VersionMismatchPolicy;
171
+ children?: ReactNode;
172
+ }
173
+ /**
174
+ * What {@link FeatureContext.useVariant} answers for one key.
175
+ *
176
+ * The `[S[K]] extends [never]` guard is what keeps a variant-free feature
177
+ * honest. `never` is a subtype of every type, so the `infer` branch alone would
178
+ * read `variant` off a decision that dropped the property and infer `unknown`.
179
+ * The tuple wrap suppresses distribution and asks the question the branch
180
+ * means to ask. Both fields come off `Decisions<S>[K]`, so this and the
181
+ * decision for the same key cannot drift apart.
182
+ */
183
+ export type BoundVariant<S extends Record<keyof S, VariantInfo | never>, K extends keyof S> = [S[K]] extends [never] ? {
184
+ variant?: undefined;
185
+ value?: undefined;
186
+ } : Decisions<S>[K] extends {
187
+ variant?: infer V;
188
+ value?: infer T;
189
+ } ? {
190
+ variant?: V;
191
+ value?: T;
192
+ } : never;
193
+ /**
194
+ * A provider and hooks bound to one store's schema.
195
+ *
196
+ * Each hook names `keyof S`, so a misspelled key is a compile error and the
197
+ * variant a reader switches on is the union that store declares.
198
+ *
199
+ * `Frozen` carries the store's own answer. A store built with no observer
200
+ * freezes nothing and its hooks answer the mutable decisions, a store built
201
+ * with one answers the deeply readonly form, and a store whose observation the
202
+ * compiler cannot settle answers the union of the two.
203
+ */
204
+ export interface FeatureContext<S extends Record<keyof S, VariantInfo | never>, Frozen extends boolean = boolean> {
205
+ FeatureProvider(props: BoundFeatureProviderProps<S, Frozen>): ReactElement;
206
+ useFeatures(): FrozenWhenObserved<Frozen, Decisions<S>>;
207
+ useFeature<K extends keyof S & FeatureKey>(key: K): FrozenWhenObserved<Frozen, Decisions<S>[K]>;
208
+ useFeatureEnabled<K extends keyof S & FeatureKey>(key: K): boolean;
209
+ useVariant<K extends keyof S & FeatureKey>(key: K): BoundVariant<S, K>;
210
+ }
211
+ /**
212
+ * Binds a store's schema to a provider and a set of hooks.
213
+ *
214
+ * `createFeatures([...])` returns a `Features<S>` whose keys and variant types the
215
+ * definitions supplied, and the store loses all of it the moment the value
216
+ * reaches a context: `createContext` fixes its type when the context
217
+ * is made, and `useContext` hands a hook that fixed type whatever the provider
218
+ * above it was given. A function that makes the context and the hooks together
219
+ * is where `S` can be held, so this is a factory and not a generic provider.
220
+ *
221
+ * `S` comes from the argument. Nothing is restated at the call site, and a
222
+ * store whose configuration arrived as JSON yields the wide hooks, which are
223
+ * the ones {@link useFeature} and its siblings already export.
224
+ *
225
+ * Each call makes its own context, so two stores nest and each set of hooks
226
+ * reads its own provider. The returned provider also publishes to the shared
227
+ * context, so a component calling the package's own {@link useFeature}
228
+ * underneath it reads the same decisions.
229
+ *
230
+ * A browser decides what a reader sees and gates nothing. A key that
231
+ * type-checks here says the store this was built from declares it, and says
232
+ * nothing about the decisions the provider was handed at runtime.
233
+ *
234
+ * @example
235
+ * ```tsx
236
+ * const features = createFeatures([
237
+ * {
238
+ * key: 'cta',
239
+ * enabled: true,
240
+ * variants: [
241
+ * { name: 'control', weight: 50 },
242
+ * { name: 'blue', weight: 50, value: { label: 'Get it' } },
243
+ * ],
244
+ * },
245
+ * ]);
246
+ *
247
+ * const { FeatureProvider, useVariant } = createFeatureContext(features);
248
+ *
249
+ * <FeatureProvider context={{ targetingKey: user.id }}>…</FeatureProvider>;
250
+ * useVariant('cta').variant; // 'control' | 'blue' | undefined
251
+ * ```
252
+ */
253
+ export declare function createFeatureContext<S extends Record<keyof S, VariantInfo | never>, Frozen extends boolean = boolean>(features: Features<S, Frozen>): FeatureContext<S, Frozen>;
254
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,118 @@
1
+ "use client";
2
+ import { createContext as e, useContext as t, useMemo as n, useState as r } from "react";
3
+ import { adoptDecisions as i, reportDivergence as a } from "@evanion/feature";
4
+ import { jsx as o } from "react/jsx-runtime";
5
+ //#region src/hydrate.ts
6
+ function s() {
7
+ try {
8
+ return process.env.NODE_ENV === "production";
9
+ } catch {
10
+ return !0;
11
+ }
12
+ }
13
+ //#endregion
14
+ //#region src/index.tsx
15
+ function c(e) {
16
+ return e;
17
+ }
18
+ var l = e(null);
19
+ function u(e) {
20
+ let n = t(e);
21
+ if (!n) throw Error("useFeatures must be called inside a <FeatureProvider> (from @evanion/react-feature)");
22
+ return n;
23
+ }
24
+ function d(e, t) {
25
+ let n = Object.prototype.hasOwnProperty.call(e, t) ? e[t] : void 0;
26
+ if (!n) throw Error(`feature "${String(t)}" is not configured in this <FeatureProvider>`);
27
+ return n;
28
+ }
29
+ var f = Object.freeze([]), p = "use-shipped";
30
+ function m(e, t, o, l, u) {
31
+ let d = l ?? p, m = u !== void 0, h = !s(), g = n(() => {
32
+ let n = [];
33
+ return {
34
+ value: { decisions: c(i(e, o, {
35
+ context: t,
36
+ onVersionMismatch: d,
37
+ onDivergence: m ? (e) => n.push(e) : void 0,
38
+ compare: h
39
+ })) },
40
+ reports: n
41
+ };
42
+ }, [
43
+ e,
44
+ t,
45
+ o,
46
+ d,
47
+ m,
48
+ h
49
+ ]), [_, v] = r(f);
50
+ if (_ !== g.reports) {
51
+ v(g.reports);
52
+ for (let e of g.reports) a(u, e);
53
+ }
54
+ return g.value;
55
+ }
56
+ function h({ features: e, context: t, decisions: n, onDivergence: r, onVersionMismatch: i, children: a }) {
57
+ let s = m(e, t, n, i, r);
58
+ return /* @__PURE__ */ o(l.Provider, {
59
+ value: s,
60
+ children: a
61
+ });
62
+ }
63
+ function g() {
64
+ return u(l).decisions;
65
+ }
66
+ function _(e) {
67
+ return d(g(), e);
68
+ }
69
+ function v(e) {
70
+ return _(e).enabled;
71
+ }
72
+ function y(e) {
73
+ let t = _(e);
74
+ return {
75
+ variant: t.variant,
76
+ value: t.value
77
+ };
78
+ }
79
+ function b(e) {
80
+ return e;
81
+ }
82
+ function x(e, t) {
83
+ return e[t];
84
+ }
85
+ function S(t) {
86
+ let n = e(null);
87
+ return {
88
+ FeatureProvider({ features: e, context: r, decisions: i, onDivergence: a, onVersionMismatch: s, children: c }) {
89
+ let u = m(e ?? t, r, i, s, a);
90
+ return /* @__PURE__ */ o(l.Provider, {
91
+ value: u,
92
+ children: /* @__PURE__ */ o(n.Provider, {
93
+ value: u,
94
+ children: c
95
+ })
96
+ });
97
+ },
98
+ useFeatures() {
99
+ return b(u(n).decisions);
100
+ },
101
+ useFeature(e) {
102
+ let t = u(n).decisions;
103
+ return d(t, e), x(t, e);
104
+ },
105
+ useFeatureEnabled(e) {
106
+ return d(u(n).decisions, e).enabled;
107
+ },
108
+ useVariant(e) {
109
+ let t = d(u(n).decisions, e);
110
+ return {
111
+ variant: t.variant,
112
+ value: t.value
113
+ };
114
+ }
115
+ };
116
+ }
117
+ //#endregion
118
+ export { h as FeatureProvider, S as createFeatureContext, _ as useFeature, v as useFeatureEnabled, g as useFeatures, y as useVariant };
package/package.json CHANGED
@@ -1,10 +1,65 @@
1
1
  {
2
2
  "name": "@evanion/react-feature",
3
- "version": "0.0.0",
4
- "description": "Placeholder. Install a released version.",
5
- "license": "MIT",
3
+ "version": "0.1.0",
4
+ "description": "The React binding for @evanion/feature: a provider and hooks over an already-built feature store.",
5
+ "keywords": [
6
+ "react",
7
+ "feature-flags",
8
+ "feature-toggles",
9
+ "rollout",
10
+ "variants",
11
+ "hooks",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://github.com/Evanion/libraries/tree/main/libs/react-feature#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/Evanion/libraries/issues"
17
+ },
6
18
  "repository": {
7
19
  "type": "git",
8
- "url": "git+https://github.com/Evanion/libraries.git"
20
+ "url": "git+https://github.com/Evanion/libraries.git",
21
+ "directory": "libs/react-feature"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Mikael Pettersson",
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ "./package.json": "./package.json",
33
+ ".": {
34
+ "@evanion/source": "./src/index.tsx",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "default": "./dist/index.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "!dist/**/*.tsbuildinfo",
43
+ "src",
44
+ "!src/**/*.test.*",
45
+ "!src/**/*.spec.*",
46
+ "!src/**/*.test-d.*",
47
+ "!src/test-setup.ts",
48
+ "README.md",
49
+ "LICENSE",
50
+ "CHANGELOG.md"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "provenance": true
55
+ },
56
+ "dependencies": {
57
+ "@evanion/feature": "0.1.0"
58
+ },
59
+ "peerDependencies": {
60
+ "react": "^18.0.0 || ^19.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@evanion/doc-examples": "*"
9
64
  }
10
65
  }
package/src/hydrate.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether this bundle is a production one.
3
+ *
4
+ * `process` is a Node global. This package ships as plain ESM with no bundling
5
+ * step and no shim, so a browser loading `@evanion/react-feature` through an
6
+ * import map holds no `process` and reading one raises a `ReferenceError`, and
7
+ * a shim that supplies `process` without `env` raises a `TypeError`. Either
8
+ * one raises out of the provider's `useMemo`, so the whole tree fails to
9
+ * mount over a question about a bundle flag.
10
+ *
11
+ * The member chain is written out whole because a bundler's `define` matches
12
+ * the text `process.env.NODE_ENV` and substitutes the string. A guard that
13
+ * read `process.env` into a local first would defeat that substitution and
14
+ * leave a production bundle running the development diff. `try` keeps the text
15
+ * a bundler replaces and still answers for a runtime that defines neither
16
+ * half.
17
+ *
18
+ * A runtime that answers neither half answers `true`, because this read gates
19
+ * work and not a message. § 4 requires the development diff to stay out of
20
+ * production, and the runtime that cannot be read is a plain ESM deployment,
21
+ * which is as often a served production page as a developer's machine. The
22
+ * diff costs a second `features.resolve` per mount, a second `'resolve'` event
23
+ * on a store built with `observe`, and a `'decision-differs'` report for every
24
+ * key an observer then has to triage. A developer who wants the diff in a
25
+ * runtime with no `process` supplies one: `globalThis.process = { env: { NODE_ENV: 'development' } }`
26
+ * ahead of the provider's first render answers the read.
27
+ *
28
+ * `createEmitter` takes the other side for the same read
29
+ * (`observe.ts:290-294`), and it gates a `console.warn` that costs a line of
30
+ * output.
31
+ */
32
+ export function production(): boolean {
33
+ try {
34
+ return process.env.NODE_ENV === 'production';
35
+ } catch {
36
+ return true;
37
+ }
38
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,615 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The React adapter: a provider and hooks over an already-built store.
5
+ *
6
+ * `'use client'` is a per-module directive, and this module carries it. The
7
+ * directive is why the adapter is a package of its own: `@evanion/feature`
8
+ * imports no framework, declares no peer and must not carry the directive,
9
+ * which a single module published from both sides cannot do.
10
+ *
11
+ * Evaluation itself lives in the core. Nothing here decides anything.
12
+ */
13
+
14
+ import { createContext, useContext, useMemo, useState } from 'react';
15
+ import type { Context, ReactElement, ReactNode } from 'react';
16
+ import { production } from './hydrate.js';
17
+ import { adoptDecisions, reportDivergence } from '@evanion/feature';
18
+ import type {
19
+ Decision,
20
+ Decisions,
21
+ DecisionSet,
22
+ DeepReadonly,
23
+ DivergenceObserver,
24
+ DivergenceReport,
25
+ EvaluationContext,
26
+ FeatureKey,
27
+ Features,
28
+ FrozenWhenObserved,
29
+ Schema,
30
+ VariantInfo,
31
+ VersionMismatchPolicy,
32
+ } from '@evanion/feature';
33
+
34
+ /**
35
+ * The document types, re-exported so a component file imports one specifier.
36
+ *
37
+ * A React application that holds a store and reloads it from a poller writes
38
+ * the handler beside the component, and that file names `ReloadResult` and
39
+ * `FeatureConfig`. Types only: `serializeConfig`, `configDigest`,
40
+ * `validateConfig` and `parseFeatureConfig` stay on the core entry, because a
41
+ * component calling one of them holds configuration in a render tree and
42
+ * reaches for the core.
43
+ */
44
+ export type {
45
+ BaseFieldType,
46
+ ConfigEnvelope,
47
+ ConfigIssue,
48
+ ConfigIssueCode,
49
+ ContextSchema,
50
+ FeatureConfig,
51
+ FeatureSchema,
52
+ FeatureShape,
53
+ FieldType,
54
+ JsonValue,
55
+ ReloadResult,
56
+ SerializedAttributeCondition,
57
+ SerializedCondition,
58
+ SerializedDefinition,
59
+ SerializedInstant,
60
+ SerializedRule,
61
+ SerializedVariantSpec,
62
+ SerializedWindowCondition,
63
+ ValidationResult,
64
+ ValueShape,
65
+ } from '@evanion/feature';
66
+
67
+ /**
68
+ * The core types this package's own public declarations name, re-exported for
69
+ * the reason the document types above are: one specifier per component file.
70
+ *
71
+ * `FeatureProviderProps` names `Features`, `EvaluationContext` and
72
+ * `DecisionSet`. `BoundFeatureProviderProps` names `Features`. `BoundVariant`
73
+ * names `Decisions`. `FeatureContext` names `Decisions`, `FeatureKey` and
74
+ * `FrozenWhenObserved`. {@link AnyDecisions} names `Decision` and
75
+ * `FeatureKey`. `Schema` is the default `FeatureProviderProps` gives its
76
+ * schema parameter, and `VariantInfo` is the constraint every one of those
77
+ * declarations writes. A reader who annotates one prop reached two specifiers
78
+ * before this clause: `@evanion/react-feature` for the prop type and
79
+ * `@evanion/feature` for the type inside it.
80
+ *
81
+ * Types only. The core's values stay on the core entry, which
82
+ * `entry-surface.spec.tsx` holds this package's module namespace to.
83
+ */
84
+ export type {
85
+ Decision,
86
+ Decisions,
87
+ EvaluationContext,
88
+ FeatureKey,
89
+ Features,
90
+ FrozenWhenObserved,
91
+ Schema,
92
+ VariantInfo,
93
+ } from '@evanion/feature';
94
+
95
+ /**
96
+ * What a provider publishes, at the erased types.
97
+ *
98
+ * Every `Decision<'cta', 'control' | 'blue', { label: string }>` assigns to
99
+ * `Decision<FeatureKey>` because `key`, `variant`, `value`, `blockedBy` and
100
+ * `cause` all sit in output position. A React context fixes its type when the
101
+ * context is created and cannot hold the schema a provider was given, so a
102
+ * provider widens to this once and the bound hooks narrow back.
103
+ *
104
+ * `Decision<FeatureKey>` and not `Decision`: the parameter defaults to `string`
105
+ * while its constraint admits a number, so a store keyed on a numeric enum
106
+ * produces a `Decision<7>` whose `key` does not assign to `Decision`'s. The
107
+ * widening only holds at the constraint.
108
+ */
109
+ export type AnyDecisions = Readonly<Record<FeatureKey, Decision<FeatureKey>>>;
110
+
111
+ export type { VersionMismatchPolicy } from '@evanion/feature';
112
+ export type { DecisionOrigin, DecisionSet } from '@evanion/feature';
113
+ export type {
114
+ DivergenceObserver,
115
+ DivergenceReport,
116
+ DivergenceSide,
117
+ } from '@evanion/feature';
118
+
119
+ interface FeatureContextValue {
120
+ decisions: AnyDecisions;
121
+ }
122
+
123
+ /**
124
+ * Reads what `resolve` answered at the erased type.
125
+ *
126
+ * A store carrying an observer answers the deeply readonly form of
127
+ * `Decisions<S>`, and `DeepReadonly` does not reduce over a schema the compiler
128
+ * has not resolved, so neither form assigns to {@link AnyDecisions} on its own.
129
+ * Both hold one decision per key, and the context reads those decisions and
130
+ * writes none of them.
131
+ */
132
+ function erased<S extends Record<keyof S, VariantInfo | never>>(
133
+ resolved: Decisions<S> | DeepReadonly<Decisions<S>>,
134
+ ): AnyDecisions {
135
+ return resolved as AnyDecisions;
136
+ }
137
+
138
+ const SharedContext = createContext<FeatureContextValue | null>(null);
139
+
140
+ function useContextValue(
141
+ context: Context<FeatureContextValue | null>,
142
+ ): FeatureContextValue {
143
+ const value = useContext(context);
144
+ if (!value) {
145
+ throw new Error(
146
+ 'useFeatures must be called inside a <FeatureProvider> (from @evanion/react-feature)',
147
+ );
148
+ }
149
+ return value;
150
+ }
151
+
152
+ /**
153
+ * One decision, or a throw naming the key.
154
+ *
155
+ * A bare index walks the prototype chain, so a key of `constructor` or
156
+ * `toString` resolves to a function off `Object.prototype` and passes the
157
+ * configured check below as a decision object. This asks the own-property
158
+ * question first.
159
+ *
160
+ * A silent `false` would make a typo indistinguishable from a feature that is
161
+ * off, which is the failure mode flag systems are worst at.
162
+ */
163
+ function requireDecision(
164
+ decisions: AnyDecisions,
165
+ key: FeatureKey,
166
+ ): Decision<FeatureKey> {
167
+ const decision = Object.prototype.hasOwnProperty.call(decisions, key)
168
+ ? decisions[key]
169
+ : undefined;
170
+ if (!decision) {
171
+ throw new Error(
172
+ `feature "${String(key)}" is not configured in this <FeatureProvider>`,
173
+ );
174
+ }
175
+ return decision;
176
+ }
177
+
178
+ /**
179
+ * What {@link FeatureProvider} takes.
180
+ *
181
+ * `features` is required, because this provider holds no store of its own.
182
+ * {@link createFeatureContext} is the form that already holds one, and
183
+ * {@link BoundFeatureProviderProps} is what its provider takes.
184
+ *
185
+ * `S` is the store's schema, and it types `decisions` and `onDivergence` on
186
+ * the keys that store declares, so a set another store resolved is a compile
187
+ * error at the mount. The hooks underneath read {@link AnyDecisions} whatever
188
+ * `S` is, which is the erasure a React context imposes and the reason the
189
+ * factory exists.
190
+ */
191
+ export interface FeatureProviderProps<
192
+ S extends Record<keyof S, VariantInfo | never> = Schema,
193
+ > {
194
+ features: Features<S>;
195
+ /**
196
+ * The evaluation context. Resolution is memoised on this object's identity, so
197
+ * pass a stable reference -- an object literal written inline re-resolves on
198
+ * every render.
199
+ *
200
+ * Leaving `now` out means "the clock at the moment this context was first
201
+ * resolved". A component tree that must agree with a server render should pass
202
+ * `now` explicitly.
203
+ */
204
+ context?: EvaluationContext;
205
+ /**
206
+ * Decisions resolved elsewhere, as the versioned set `Features.snapshot`
207
+ * produces. A server render hands the client the set it resolved, and this
208
+ * provider publishes it.
209
+ *
210
+ * The set states the config version it was resolved under and the instant it
211
+ * was resolved at, so this provider can tell whether the two processes hold
212
+ * one configuration and can resolve the remainder at the server's instant.
213
+ */
214
+ decisions?: DecisionSet<S>;
215
+ /** Notified for every divergence this provider detects. Alters no decision. */
216
+ onDivergence?: DivergenceObserver<keyof S & FeatureKey>;
217
+ /**
218
+ * What to render when the shipped version is not the store's. Defaults to
219
+ * `'use-shipped'`.
220
+ *
221
+ * An application that runs no experiment and cares about revocation latency
222
+ * passes `'re-resolve'`.
223
+ */
224
+ onVersionMismatch?: VersionMismatchPolicy;
225
+ children?: ReactNode;
226
+ }
227
+
228
+ /** The pass no render has delivered, so a mount delivers its own. */
229
+ const UNDELIVERED: readonly never[] = Object.freeze([]);
230
+
231
+ /**
232
+ * What a provider does about a version mismatch where an application states
233
+ * nothing.
234
+ *
235
+ * One constant for both providers. A wrong experiment result is the failure
236
+ * that produces no error, no warning and no way to notice afterwards, and a
237
+ * stale kill switch produces a complaint within minutes (§ 3), so the two
238
+ * providers cannot hold different answers and a second literal is a second
239
+ * answer waiting to drift.
240
+ */
241
+ const ON_MISMATCH: VersionMismatchPolicy = 'use-shipped';
242
+
243
+ /**
244
+ * The value a provider publishes, with one pass of the checks per set of
245
+ * inputs and every report of that pass handed to the observer the current
246
+ * render holds.
247
+ *
248
+ * § 4 requires both checks to report before React reconciles, so
249
+ * `adoptDecisions` runs inside the memo body and the memo's dependency
250
+ * list decides how often each check runs. An observer prop written inline is a
251
+ * new function on every render, and a memo depending on its identity re-ran
252
+ * the sufficiency pass, re-resolved the store for the diff and re-sent every
253
+ * report for a parent re-rendering on a keystroke. § 4 budgets one pass of
254
+ * each per mount and § 3 reports `'unversioned'` once. The memo therefore
255
+ * reads the prop's presence and not its identity, and presence is what
256
+ * `adoptDecisions` asks about: it resolves for the diff only where
257
+ * something reads the result.
258
+ *
259
+ * The memo collects rather than delivers, because the observer a pass belongs
260
+ * to is the prop of the render that produced it and the memo body holds the
261
+ * prop of the render that last re-ran. A provider whose `onDivergence` turns
262
+ * from `undefined` into a function re-runs both checks in that render, and
263
+ * every report of that pass belongs to the function that just arrived. The
264
+ * same holds for a render that swaps one sink for another.
265
+ *
266
+ * The state update is what stops a second delivery. Every pass of the memo
267
+ * builds its own array, so the array identity names the pass, and a render
268
+ * that delivers writes it back as state. React re-renders this component with
269
+ * that state before it commits, the memo holds its result, and the comparison
270
+ * then matches. A re-render that recomputes nothing reaches the same array and
271
+ * delivers nothing.
272
+ *
273
+ * React invokes a render twice under `StrictMode` and discards the first,
274
+ * which sends every report of a pass twice. An observer counts divergences and
275
+ * alters nothing (§ 7), so the duplicate costs a console line.
276
+ */
277
+ function usePublished<S extends Record<keyof S, VariantInfo | never>>(
278
+ features: Features<S>,
279
+ context: EvaluationContext | undefined,
280
+ decisions: DecisionSet<S> | undefined,
281
+ onVersionMismatch: VersionMismatchPolicy | undefined,
282
+ onDivergence: DivergenceObserver<keyof S & FeatureKey> | undefined,
283
+ ): FeatureContextValue {
284
+ const policy = onVersionMismatch ?? ON_MISMATCH;
285
+ const installed = onDivergence !== undefined;
286
+ const compare = !production();
287
+ const published = useMemo(() => {
288
+ const reports: DivergenceReport<keyof S & FeatureKey>[] = [];
289
+ const value: FeatureContextValue = {
290
+ decisions: erased(
291
+ adoptDecisions(features, decisions, {
292
+ context,
293
+ onVersionMismatch: policy,
294
+ onDivergence: installed
295
+ ? (report) => reports.push(report)
296
+ : undefined,
297
+ compare,
298
+ }),
299
+ ),
300
+ };
301
+ return { value, reports };
302
+ }, [features, context, decisions, policy, installed, compare]);
303
+
304
+ const [delivered, setDelivered] =
305
+ useState<readonly DivergenceReport<keyof S & FeatureKey>[]>(UNDELIVERED);
306
+ if (delivered !== published.reports) {
307
+ setDelivered(published.reports);
308
+ for (const report of published.reports) {
309
+ reportDivergence(onDivergence, report);
310
+ }
311
+ }
312
+
313
+ return published.value;
314
+ }
315
+
316
+ /**
317
+ * Publishes one store's decisions to every component under it.
318
+ *
319
+ * With `decisions` left out the provider resolves `features` against
320
+ * `context`, once per identity of the two. With a set supplied it publishes
321
+ * that set: the server rendered HTML from those decisions and counted an
322
+ * exposure for each variant it assigned, so a client answering differently
323
+ * makes an experiment result wrong by an amount no analysis recovers. A
324
+ * version mismatch renders the shipped set unless `onVersionMismatch` names
325
+ * `'re-resolve'`.
326
+ *
327
+ * Decides nothing itself. `@evanion/feature` resolves, and this component
328
+ * writes the answer into React context and renders `children`.
329
+ *
330
+ * Every hook this package exports throws outside a provider, and the message
331
+ * names the provider and the package. {@link useFeature},
332
+ * {@link useFeatureEnabled} and {@link useVariant} throw for a key the
333
+ * published decisions do not carry.
334
+ */
335
+ export function FeatureProvider<
336
+ S extends Record<keyof S, VariantInfo | never>,
337
+ >({
338
+ features,
339
+ context,
340
+ decisions,
341
+ onDivergence,
342
+ onVersionMismatch,
343
+ children,
344
+ }: FeatureProviderProps<S>) {
345
+ const value = usePublished(
346
+ features,
347
+ context,
348
+ decisions,
349
+ onVersionMismatch,
350
+ onDivergence,
351
+ );
352
+
353
+ return (
354
+ <SharedContext.Provider value={value}>{children}</SharedContext.Provider>
355
+ );
356
+ }
357
+
358
+ /** Every resolved decision, at the erased types. */
359
+ export function useFeatures(): AnyDecisions {
360
+ return useContextValue(SharedContext).decisions;
361
+ }
362
+
363
+ /**
364
+ * One feature's decision, explanation included.
365
+ *
366
+ * Throws for a key that is not configured. {@link createFeatureContext} binds
367
+ * this hook to one store's keys, so a component under a bound provider names a
368
+ * key the compiler checks.
369
+ */
370
+ export function useFeature(key: FeatureKey): Decision<FeatureKey> {
371
+ return requireDecision(useFeatures(), key);
372
+ }
373
+
374
+ /** One feature's decision as a boolean. */
375
+ export function useFeatureEnabled(key: FeatureKey): boolean {
376
+ return useFeature(key).enabled;
377
+ }
378
+
379
+ /**
380
+ * One feature's assigned variant and its value.
381
+ *
382
+ * A reader over the decision `useFeature` already returns. Both are absent for
383
+ * a feature that resolved off and for a feature declaring no variants, so
384
+ * calling code handles off before it switches on a name.
385
+ *
386
+ * Throws for a key the provider does not carry, which `useFeature` does for the
387
+ * same reason: a silent `undefined` would make a typo indistinguishable from a
388
+ * feature nobody assigned.
389
+ */
390
+ export function useVariant(
391
+ key: FeatureKey,
392
+ ): Pick<Decision, 'variant' | 'value'> {
393
+ const decision = useFeature(key);
394
+ return { variant: decision.variant, value: decision.value };
395
+ }
396
+
397
+ /**
398
+ * Props of the provider {@link createFeatureContext} returns.
399
+ *
400
+ * `features` is optional here and required on {@link FeatureProvider}: the
401
+ * factory already holds a store, and a mount that supplies the context alone is
402
+ * the common one. Pass it to resolve a different store with the same schema --
403
+ * a per-tenant configuration, or the copy a browser rebuilt from JSON.
404
+ *
405
+ * `Frozen` comes from the factory's own store, and a substituted store carries
406
+ * the same parameter. The hooks type their decisions off the factory's answer,
407
+ * so a store that freezes what it resolves under a factory built from a store
408
+ * that freezes nothing would publish frozen decisions to hooks promising the
409
+ * mutable form. The parameter here turns that mount into a compile error.
410
+ */
411
+ export interface BoundFeatureProviderProps<
412
+ S extends Record<keyof S, VariantInfo | never>,
413
+ Frozen extends boolean = boolean,
414
+ > {
415
+ features?: Features<S, Frozen>;
416
+ context?: EvaluationContext;
417
+ /**
418
+ * Decisions resolved elsewhere, as the versioned set `Features.snapshot`
419
+ * produces. A server render hands the client the set it resolved, and this
420
+ * provider publishes it.
421
+ */
422
+ decisions?: DecisionSet<S, Frozen>;
423
+ /** Notified for every divergence this provider detects. Alters no decision. */
424
+ onDivergence?: DivergenceObserver<keyof S & FeatureKey>;
425
+ /**
426
+ * What to render when the shipped version is not the store's. Defaults to
427
+ * `'use-shipped'`.
428
+ */
429
+ onVersionMismatch?: VersionMismatchPolicy;
430
+ children?: ReactNode;
431
+ }
432
+
433
+ /**
434
+ * What {@link FeatureContext.useVariant} answers for one key.
435
+ *
436
+ * The `[S[K]] extends [never]` guard is what keeps a variant-free feature
437
+ * honest. `never` is a subtype of every type, so the `infer` branch alone would
438
+ * read `variant` off a decision that dropped the property and infer `unknown`.
439
+ * The tuple wrap suppresses distribution and asks the question the branch
440
+ * means to ask. Both fields come off `Decisions<S>[K]`, so this and the
441
+ * decision for the same key cannot drift apart.
442
+ */
443
+ export type BoundVariant<
444
+ S extends Record<keyof S, VariantInfo | never>,
445
+ K extends keyof S,
446
+ > = [S[K]] extends [never]
447
+ ? { variant?: undefined; value?: undefined }
448
+ : Decisions<S>[K] extends { variant?: infer V; value?: infer T }
449
+ ? { variant?: V; value?: T }
450
+ : never;
451
+
452
+ /**
453
+ * A provider and hooks bound to one store's schema.
454
+ *
455
+ * Each hook names `keyof S`, so a misspelled key is a compile error and the
456
+ * variant a reader switches on is the union that store declares.
457
+ *
458
+ * `Frozen` carries the store's own answer. A store built with no observer
459
+ * freezes nothing and its hooks answer the mutable decisions, a store built
460
+ * with one answers the deeply readonly form, and a store whose observation the
461
+ * compiler cannot settle answers the union of the two.
462
+ */
463
+ export interface FeatureContext<
464
+ S extends Record<keyof S, VariantInfo | never>,
465
+ Frozen extends boolean = boolean,
466
+ > {
467
+ FeatureProvider(props: BoundFeatureProviderProps<S, Frozen>): ReactElement;
468
+ useFeatures(): FrozenWhenObserved<Frozen, Decisions<S>>;
469
+ useFeature<K extends keyof S & FeatureKey>(
470
+ key: K,
471
+ ): FrozenWhenObserved<Frozen, Decisions<S>[K]>;
472
+ useFeatureEnabled<K extends keyof S & FeatureKey>(key: K): boolean;
473
+ useVariant<K extends keyof S & FeatureKey>(key: K): BoundVariant<S, K>;
474
+ }
475
+
476
+ /**
477
+ * Reads a bound provider's decisions at the schema the factory holds.
478
+ *
479
+ * This is the one place the erased value is narrowed. It holds because a bound
480
+ * context is created inside a single {@link createFeatureContext} call and
481
+ * written by exactly one component, whose props type every entry as one of the
482
+ * two forms of `Decisions<S>`.
483
+ *
484
+ * The answer follows the store. A provider fed by a store carrying an observer
485
+ * publishes frozen decisions, and a hook promising the mutable form there would
486
+ * let a component write a field the runtime refuses. The compiler resolves
487
+ * neither branch of `FrozenWhenObserved` while `Frozen` is a parameter, so the
488
+ * cast states the answer the two branches share.
489
+ */
490
+ function atSchema<
491
+ S extends Record<keyof S, VariantInfo | never>,
492
+ Frozen extends boolean,
493
+ >(decisions: AnyDecisions): FrozenWhenObserved<Frozen, Decisions<S>> {
494
+ return decisions as unknown as FrozenWhenObserved<Frozen, Decisions<S>>;
495
+ }
496
+
497
+ /**
498
+ * Reads one key off a bound provider's decisions, at the schema.
499
+ *
500
+ * `DeepReadonly<Decisions<S>>` is a conditional type the compiler cannot reduce
501
+ * while `S` is a parameter, so nothing indexes it by `K`. Indexing first and
502
+ * mapping the one decision answers the same type for a settled `S`.
503
+ */
504
+ function decisionAtSchema<
505
+ S extends Record<keyof S, VariantInfo | never>,
506
+ K extends keyof S & FeatureKey,
507
+ Frozen extends boolean,
508
+ >(
509
+ decisions: AnyDecisions,
510
+ key: K,
511
+ ): FrozenWhenObserved<Frozen, Decisions<S>[K]> {
512
+ return (decisions as Decisions<S>)[key] as unknown as FrozenWhenObserved<
513
+ Frozen,
514
+ Decisions<S>[K]
515
+ >;
516
+ }
517
+
518
+ /**
519
+ * Binds a store's schema to a provider and a set of hooks.
520
+ *
521
+ * `createFeatures([...])` returns a `Features<S>` whose keys and variant types the
522
+ * definitions supplied, and the store loses all of it the moment the value
523
+ * reaches a context: `createContext` fixes its type when the context
524
+ * is made, and `useContext` hands a hook that fixed type whatever the provider
525
+ * above it was given. A function that makes the context and the hooks together
526
+ * is where `S` can be held, so this is a factory and not a generic provider.
527
+ *
528
+ * `S` comes from the argument. Nothing is restated at the call site, and a
529
+ * store whose configuration arrived as JSON yields the wide hooks, which are
530
+ * the ones {@link useFeature} and its siblings already export.
531
+ *
532
+ * Each call makes its own context, so two stores nest and each set of hooks
533
+ * reads its own provider. The returned provider also publishes to the shared
534
+ * context, so a component calling the package's own {@link useFeature}
535
+ * underneath it reads the same decisions.
536
+ *
537
+ * A browser decides what a reader sees and gates nothing. A key that
538
+ * type-checks here says the store this was built from declares it, and says
539
+ * nothing about the decisions the provider was handed at runtime.
540
+ *
541
+ * @example
542
+ * ```tsx
543
+ * const features = createFeatures([
544
+ * {
545
+ * key: 'cta',
546
+ * enabled: true,
547
+ * variants: [
548
+ * { name: 'control', weight: 50 },
549
+ * { name: 'blue', weight: 50, value: { label: 'Get it' } },
550
+ * ],
551
+ * },
552
+ * ]);
553
+ *
554
+ * const { FeatureProvider, useVariant } = createFeatureContext(features);
555
+ *
556
+ * <FeatureProvider context={{ targetingKey: user.id }}>…</FeatureProvider>;
557
+ * useVariant('cta').variant; // 'control' | 'blue' | undefined
558
+ * ```
559
+ */
560
+ export function createFeatureContext<
561
+ S extends Record<keyof S, VariantInfo | never>,
562
+ Frozen extends boolean = boolean,
563
+ >(features: Features<S, Frozen>): FeatureContext<S, Frozen> {
564
+ const BoundContext = createContext<FeatureContextValue | null>(null);
565
+
566
+ return {
567
+ FeatureProvider({
568
+ features: given,
569
+ context,
570
+ decisions,
571
+ onDivergence,
572
+ onVersionMismatch,
573
+ children,
574
+ }) {
575
+ const value = usePublished(
576
+ given ?? features,
577
+ context,
578
+ decisions,
579
+ onVersionMismatch,
580
+ onDivergence,
581
+ );
582
+
583
+ return (
584
+ <SharedContext.Provider value={value}>
585
+ <BoundContext.Provider value={value}>
586
+ {children}
587
+ </BoundContext.Provider>
588
+ </SharedContext.Provider>
589
+ );
590
+ },
591
+ useFeatures() {
592
+ return atSchema<S, Frozen>(useContextValue(BoundContext).decisions);
593
+ },
594
+ useFeature(key) {
595
+ const decisions = useContextValue(BoundContext).decisions;
596
+ requireDecision(decisions, key);
597
+ return decisionAtSchema<S, typeof key, Frozen>(decisions, key);
598
+ },
599
+ useFeatureEnabled(key) {
600
+ return requireDecision(useContextValue(BoundContext).decisions, key)
601
+ .enabled;
602
+ },
603
+ useVariant(key) {
604
+ const decision = requireDecision(
605
+ useContextValue(BoundContext).decisions,
606
+ key,
607
+ );
608
+ // A method whose return type is conditional on an unresolved `S` has no
609
+ // type an implementation can write. The compiler resolves neither branch
610
+ // here, and the runtime answer is the two fields the decision carries
611
+ // either way.
612
+ return { variant: decision.variant, value: decision.value } as never;
613
+ },
614
+ };
615
+ }