@evanion/react-feature 0.0.0-stage → 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 +22 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/hydrate.d.ts +33 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +118 -0
- package/package.json +63 -4
- package/src/hydrate.ts +38 -0
- package/src/index.tsx +615 -0
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
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @evanion/react-feature
|
|
2
2
|
|
|
3
|
-
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evanion/react-feature",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "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": "*"
|
|
64
|
+
}
|
|
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
|
+
}
|