@flagdown/react 0.0.5 → 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.5
6
+ npm install @flagdown/react@0.0.6
7
7
  ```
8
8
 
9
9
  ```tsx
@@ -49,10 +49,36 @@ 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
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,14 +7,33 @@ 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 = {
@@ -23,6 +42,7 @@ var disabledFlagEvaluator = {
23
42
 
24
43
  // src/api-types.ts
25
44
  var clientFlagsPath = "/v1/client/flags";
45
+ var clientErrorsPath = "/v1/client/errors";
26
46
 
27
47
  // src/remote.ts
28
48
  async function fetchFlagState(apiUrl, clientToken, signal) {
@@ -45,13 +65,72 @@ async function fetchFlagState(apiUrl, clientToken, signal) {
45
65
  return result.flags;
46
66
  }
47
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
+ }
116
+ }
117
+
48
118
  // src/FlagdownProvider.tsx
49
119
  import { jsx as jsx2 } from "react/jsx-runtime";
50
- function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, onError }) {
120
+ function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, captureErrors = true, onError }) {
51
121
  const errorHandler = useRef(onError);
52
122
  useEffect(() => {
53
123
  errorHandler.current = onError;
54
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]);
55
134
  const [snapshot, setSnapshot] = useState();
56
135
  useEffect(() => {
57
136
  if (!apiUrl || !clientToken) return;
@@ -84,7 +163,11 @@ function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1
84
163
  controller?.abort();
85
164
  };
86
165
  }, [apiUrl, clientToken, refreshIntervalMs]);
87
- 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]);
88
171
  return /* @__PURE__ */ jsx2(FlagdownContext.Provider, { value, children });
89
172
  }
90
173
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flagdown/react",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "React SDK for evaluating Flagdown feature flags.",