@flagdown/react 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Use the `Feature` component to switch between new UI and a fallback in React 18+.
4
4
 
5
5
  ```sh
6
- npm install @flagdown/react@0.0.4
6
+ npm install @flagdown/react@0.0.6
7
7
  ```
8
8
 
9
9
  ```tsx
@@ -49,11 +49,47 @@ a successful later refresh restores values. An optional `onError(error)` callbac
49
49
  can report failures. Changing project configuration immediately discards the
50
50
  previous project's values. Unmounting cancels in-flight work and future refreshes.
51
51
 
52
- Client tokens are public, project-scoped read configuration. They cannot manage
53
- flags. Replacing a token in the dashboard invalidates the previous token; update
52
+ Client tokens are public and project-scoped. They read flags and submit feature
53
+ error reports; they cannot manage flags or read error reports. Replacing a token in the dashboard invalidates the previous token; update
54
54
  apps using it. Client-side flags are not an authorization boundary.
55
55
 
56
+ ### Feature errors
57
+
58
+ `Feature` catches errors thrown by its enabled descendants during rendering,
59
+ constructors and React lifecycle/effect work. It renders `fallback` (or nothing)
60
+ and submits one best-effort error report, including the flag key, bounded error
61
+ message, JavaScript stack, React component stack and SDK version. Read reports in
62
+ the dashboard's **Recent feature errors** section. Errors do not change the flag
63
+ for other users. Repeated polls keep a crashed feature on its fallback. Remount
64
+ the feature, toggle it off then on, or change project configuration to retry it.
65
+
66
+ `onError` receives both flag-read failures and caught feature errors. Reporting
67
+ failures never recurse or prevent the fallback from rendering. Uploads time out
68
+ after five seconds and are not retried. Set `captureErrors={false}` on the provider
69
+ to disable uploads while retaining fallback behavior and the `onError` callback.
70
+
71
+ Only the enabled subtree is covered. Errors in event handlers, asynchronous
72
+ callbacks/promises, server rendering, and the fallback itself are not caught.
73
+ Nested features use the nearest boundary. No global browser listeners, cookies,
74
+ user identity or page URL are collected. URL queries/fragments and the configured
75
+ client token are removed from report text. Error messages and stacks can still
76
+ contain application data; disable uploads if those strings contain sensitive data.
77
+
78
+ The API accepts up to 60 new reports per project per minute, retains the latest
79
+ 100 reports, and returns the newest 50 to authenticated project members. Client
80
+ reports are untrusted diagnostic data, not evidence for automatic rollback.
81
+
56
82
  `Feature` is the SDK's flag consumption API. The evaluation context and evaluator
57
83
  are internal; the package exposes no flag hooks or direct evaluation functions.
58
84
  Use `FlagdownProvider` to configure your project. A provider without project
59
85
  configuration renders each feature's fallback.
86
+
87
+ API transport types are generated from `openapi/flagdown-api.json` with
88
+ `openapi-typescript` before SDK typechecking and building. They stay internal;
89
+ the public exports remain `Feature`, `FlagdownProvider` and their prop types.
90
+ The runtime still rejects malformed or non-boolean flag values.
91
+
92
+ For SDK development, run `npm run --workspace @flagdown/react generate:types`
93
+ to regenerate `src/generated/api.d.ts`. This file is ignored by git and is never
94
+ the source of truth. Backend CI checks candidate contracts against the current
95
+ SDK before publishing the generated specification to this repository.
package/dist/index.d.ts CHANGED
@@ -4,19 +4,22 @@ import { PropsWithChildren, ReactNode } from 'react';
4
4
  type FeatureProps = PropsWithChildren<{
5
5
  /** Key of the flag controlling this feature. */
6
6
  flag: string;
7
- /** Rendered while the flag is off, unknown, loading, or unavailable. */
7
+ /** Rendered while the flag is off, unavailable, or its enabled UI has crashed. */
8
8
  fallback?: ReactNode;
9
9
  }>;
10
10
  declare function Feature({ flag, fallback, children }: FeatureProps): react.JSX.Element;
11
11
 
12
12
  interface FlagdownProviderProps extends PropsWithChildren {
13
13
  apiUrl?: string;
14
- /** Public, read-only project token copied from the dashboard. */
14
+ /** Public project token for flag reads and feature error reports. */
15
15
  clientToken?: string;
16
16
  /** Refresh interval; minimum 1 second, default 10 seconds. */
17
17
  refreshIntervalMs?: number;
18
+ /** Send errors caught by Feature to the project's recent error list. Default true. */
19
+ captureErrors?: boolean;
20
+ /** Called for flag-read failures and errors caught by Feature. */
18
21
  onError?: (error: Error) => void;
19
22
  }
20
- declare function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs, onError }: FlagdownProviderProps): react.JSX.Element;
23
+ declare function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs, captureErrors, onError }: FlagdownProviderProps): react.JSX.Element;
21
24
 
22
25
  export { Feature, type FeatureProps, FlagdownProvider, type FlagdownProviderProps };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/Feature.tsx
2
- import { useContext } from "react";
2
+ import { Component, useContext } from "react";
3
3
 
4
4
  // src/FlagdownContext.tsx
5
5
  import { createContext } from "react";
@@ -7,42 +7,130 @@ var FlagdownContext = createContext(void 0);
7
7
 
8
8
  // src/Feature.tsx
9
9
  import { Fragment, jsx } from "react/jsx-runtime";
10
+ var FeatureBoundary = class extends Component {
11
+ state = { failed: false, scope: this.props.scope };
12
+ static getDerivedStateFromProps(props, state) {
13
+ return props.scope === state.scope ? null : { failed: false, scope: props.scope };
14
+ }
15
+ static getDerivedStateFromError() {
16
+ return { failed: true };
17
+ }
18
+ componentDidCatch(error, info) {
19
+ try {
20
+ this.props.reportError?.(this.props.flag, error, info.componentStack ?? "");
21
+ } catch {
22
+ }
23
+ }
24
+ render() {
25
+ return this.state.failed ? this.props.fallback : this.props.children;
26
+ }
27
+ };
10
28
  function Feature({ flag, fallback = null, children }) {
11
29
  const evaluator = useContext(FlagdownContext);
12
30
  if (!evaluator) throw new Error("Feature must be used within a FlagdownProvider");
13
- return /* @__PURE__ */ jsx(Fragment, { children: evaluator.isEnabled(flag) ? children : fallback });
31
+ if (!evaluator.isEnabled(flag)) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
32
+ return /* @__PURE__ */ jsx(FeatureBoundary, { flag, fallback, scope: evaluator.scope, reportError: evaluator.reportError, children }, flag);
14
33
  }
15
34
 
16
35
  // src/FlagdownProvider.tsx
17
- import { useEffect, useMemo, useState, useRef } from "react";
36
+ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
18
37
 
19
38
  // src/flags.ts
20
39
  var disabledFlagEvaluator = {
21
40
  isEnabled: () => false
22
41
  };
23
42
 
43
+ // src/api-types.ts
44
+ var clientFlagsPath = "/v1/client/flags";
45
+ var clientErrorsPath = "/v1/client/errors";
46
+
24
47
  // src/remote.ts
25
48
  async function fetchFlagState(apiUrl, clientToken, signal) {
26
- const response = await fetch(apiUrl.replace(/\/$/, "") + "/v1/client/flags", {
27
- headers: { "x-flagdown-client-token": clientToken },
49
+ const headers = { "x-flagdown-client-token": clientToken };
50
+ const response = await fetch(apiUrl.replace(/\/$/, "") + clientFlagsPath, {
51
+ headers,
28
52
  cache: "no-store",
29
53
  signal
30
54
  });
31
55
  if (!response.ok) throw new Error(`Flagdown request failed (${response.status})`);
32
56
  const data = await response.json();
33
- if (!data || typeof data !== "object" || !("flags" in data) || !data.flags || typeof data.flags !== "object" || Array.isArray(data.flags) || Object.values(data.flags).some((value) => typeof value !== "boolean")) {
57
+ if (!data || typeof data !== "object" || !("flags" in data) || !data.flags || typeof data.flags !== "object" || Array.isArray(data.flags)) {
34
58
  throw new Error("Invalid Flagdown response");
35
59
  }
36
- return data.flags;
60
+ const flags = Object.fromEntries(Object.entries(data.flags).map(([key, enabled]) => {
61
+ if (typeof enabled !== "boolean") throw new Error("Invalid Flagdown response");
62
+ return [key, enabled];
63
+ }));
64
+ const result = { flags };
65
+ return result.flags;
66
+ }
67
+
68
+ // package.json
69
+ var version = "0.0.6";
70
+
71
+ // src/errors.ts
72
+ function normalizeFeatureError(value) {
73
+ try {
74
+ return value instanceof Error ? value : new Error(String(value));
75
+ } catch {
76
+ return new Error("Unknown feature error");
77
+ }
78
+ }
79
+ async function reportFeatureError(apiUrl, clientToken, flagKey, error, componentStack) {
80
+ const controller = new AbortController();
81
+ const timeout = setTimeout(() => controller.abort(), 5e3);
82
+ try {
83
+ const clean = (text, limit) => text.replaceAll(clientToken, "[client token]").replace(/https?:\/\/[^\s)]+/g, (url) => url.replace(/[?#].*$/, "")).slice(0, limit);
84
+ const report = {
85
+ eventId: crypto.randomUUID(),
86
+ flagKey,
87
+ name: clean(error.name || "Error", 100),
88
+ message: clean(error.message, 1e3),
89
+ stack: error.stack ? clean(error.stack, 4e3) : void 0,
90
+ componentStack: clean(componentStack, 2e3),
91
+ sdkVersion: version
92
+ };
93
+ let body = JSON.stringify(report);
94
+ if (new TextEncoder().encode(body).byteLength > 16384) {
95
+ delete report.stack;
96
+ delete report.componentStack;
97
+ body = JSON.stringify(report);
98
+ }
99
+ const headers = {
100
+ "x-flagdown-client-token": clientToken,
101
+ "Content-Type": "application/json"
102
+ };
103
+ await fetch(apiUrl.replace(/\/$/, "") + clientErrorsPath, {
104
+ method: "POST",
105
+ headers,
106
+ body,
107
+ signal: controller.signal,
108
+ keepalive: true,
109
+ credentials: "omit",
110
+ cache: "no-store"
111
+ });
112
+ } catch {
113
+ } finally {
114
+ clearTimeout(timeout);
115
+ }
37
116
  }
38
117
 
39
118
  // src/FlagdownProvider.tsx
40
119
  import { jsx as jsx2 } from "react/jsx-runtime";
41
- function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, onError }) {
120
+ function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, captureErrors = true, onError }) {
42
121
  const errorHandler = useRef(onError);
43
122
  useEffect(() => {
44
123
  errorHandler.current = onError;
45
124
  }, [onError]);
125
+ const scope = useMemo(() => ({}), [apiUrl, clientToken]);
126
+ const reportError = useCallback((flagKey, value2, componentStack) => {
127
+ const error = normalizeFeatureError(value2);
128
+ if (captureErrors && apiUrl && clientToken) void reportFeatureError(apiUrl, clientToken, flagKey, error, componentStack);
129
+ try {
130
+ errorHandler.current?.(error);
131
+ } catch {
132
+ }
133
+ }, [apiUrl, clientToken, captureErrors]);
46
134
  const [snapshot, setSnapshot] = useState();
47
135
  useEffect(() => {
48
136
  if (!apiUrl || !clientToken) return;
@@ -75,7 +163,11 @@ function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1
75
163
  controller?.abort();
76
164
  };
77
165
  }, [apiUrl, clientToken, refreshIntervalMs]);
78
- const value = useMemo(() => snapshot && snapshot.apiUrl === apiUrl && snapshot.clientToken === clientToken ? { isEnabled: (key) => Object.hasOwn(snapshot.flags, key) && snapshot.flags[key] === true } : disabledFlagEvaluator, [snapshot, apiUrl, clientToken]);
166
+ const value = useMemo(() => ({
167
+ scope,
168
+ reportError,
169
+ ...snapshot && snapshot.apiUrl === apiUrl && snapshot.clientToken === clientToken ? { isEnabled: (key) => Object.hasOwn(snapshot.flags, key) && snapshot.flags[key] === true } : disabledFlagEvaluator
170
+ }), [snapshot, apiUrl, clientToken, scope, reportError]);
79
171
  return /* @__PURE__ */ jsx2(FlagdownContext.Provider, { value, children });
80
172
  }
81
173
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flagdown/react",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "React SDK for evaluating Flagdown feature flags.",
@@ -28,9 +28,10 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "scripts": {
31
- "build": "tsup src/index.ts --format esm --dts --clean",
31
+ "generate:types": "node scripts/generate-api-types.mjs",
32
+ "build": "node scripts/generate-api-types.mjs && tsup src/index.ts --format esm --dts --clean",
32
33
  "test": "bun test",
33
- "typecheck": "tsc --noEmit"
34
+ "typecheck": "node scripts/generate-api-types.mjs && tsc --noEmit"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "react": ">=18",
@@ -38,6 +39,7 @@
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/react": "^18",
42
+ "openapi-typescript": "7.13.0",
41
43
  "react": "^18",
42
44
  "react-dom": "^18",
43
45
  "tsup": "^8",