@checkstack/ui 1.8.3 → 1.9.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.
@@ -33,6 +33,9 @@ export const FormField: React.FC<FormFieldProps> = ({
33
33
  formValues,
34
34
  optionsResolvers,
35
35
  templateProperties,
36
+ typeDefinitions,
37
+ shellEnvVars,
38
+ starterTemplates,
36
39
  onChange,
37
40
  }) => {
38
41
  const description = propSchema.description || "";
@@ -129,6 +132,9 @@ export const FormField: React.FC<FormFieldProps> = ({
129
132
  isRequired={isRequired}
130
133
  editorTypes={editorTypes}
131
134
  templateProperties={templateProperties}
135
+ typeDefinitions={typeDefinitions}
136
+ shellEnvVars={shellEnvVars}
137
+ starterTemplates={starterTemplates}
132
138
  onChange={onChange as (val: string | undefined) => void}
133
139
  />
134
140
  );
@@ -346,6 +352,9 @@ export const FormField: React.FC<FormFieldProps> = ({
346
352
  formValues={formValues}
347
353
  optionsResolvers={optionsResolvers}
348
354
  templateProperties={templateProperties}
355
+ typeDefinitions={typeDefinitions}
356
+ shellEnvVars={shellEnvVars}
357
+ starterTemplates={starterTemplates}
349
358
  onChange={(val) =>
350
359
  onChange({ ...(value as Record<string, unknown>), [key]: val })
351
360
  }
@@ -454,6 +463,9 @@ export const FormField: React.FC<FormFieldProps> = ({
454
463
  formValues={formValues}
455
464
  optionsResolvers={optionsResolvers}
456
465
  templateProperties={templateProperties}
466
+ typeDefinitions={typeDefinitions}
467
+ shellEnvVars={shellEnvVars}
468
+ starterTemplates={starterTemplates}
457
469
  onChange={(val) => {
458
470
  const next = [...(items as unknown[])];
459
471
  next[index] = val;
@@ -582,6 +594,9 @@ export const FormField: React.FC<FormFieldProps> = ({
582
594
  formValues={formValues}
583
595
  optionsResolvers={optionsResolvers}
584
596
  templateProperties={templateProperties}
597
+ typeDefinitions={typeDefinitions}
598
+ shellEnvVars={shellEnvVars}
599
+ starterTemplates={starterTemplates}
585
600
  onChange={(val) => onChange({ ...currentValue, [key]: val })}
586
601
  />
587
602
  ))}
@@ -17,6 +17,8 @@ import {
17
17
  parseFormData,
18
18
  EDITOR_TYPE_LABELS,
19
19
  } from "./utils";
20
+ import type { EditorStarterTemplates, ShellEnvVar } from "./types";
21
+ import { pickStarterForEmptyField } from "./starterTemplateSelector";
20
22
 
21
23
  export interface MultiTypeEditorFieldProps {
22
24
  /** Unique identifier for the field */
@@ -33,6 +35,25 @@ export interface MultiTypeEditorFieldProps {
33
35
  editorTypes: EditorType[];
34
36
  /** Optional template properties for autocomplete */
35
37
  templateProperties?: TemplateProperty[];
38
+ /**
39
+ * Optional TypeScript declarations injected into Monaco for TS/JS modes —
40
+ * powers IntelliSense + return-shape error reporting against the runtime
41
+ * context the user will see (`context.config`, `context.event`, ...).
42
+ */
43
+ typeDefinitions?: string;
44
+ /**
45
+ * Optional shell env-var hints surfaced as completion items after `$`
46
+ * (and `${`) in shell mode. Typically populated by the platform with
47
+ * names like `EVENT_ID` / `PAYLOAD_*` so users don't have to memorise.
48
+ */
49
+ shellEnvVars?: ShellEnvVar[];
50
+ /**
51
+ * Optional starter templates per editor language. When the field is
52
+ * empty (and the user hasn't typed yet), switching to an editor with a
53
+ * starter for that language pre-populates the editor so users see a
54
+ * working example instead of a blank canvas.
55
+ */
56
+ starterTemplates?: EditorStarterTemplates;
36
57
  /** Callback when value changes */
37
58
  onChange: (value: string | undefined) => void;
38
59
  }
@@ -50,6 +71,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
50
71
  isRequired,
51
72
  editorTypes,
52
73
  templateProperties,
74
+ typeDefinitions,
75
+ shellEnvVars,
76
+ starterTemplates,
53
77
  onChange,
54
78
  }) => {
55
79
  // Detect initial editor type from value
@@ -57,16 +81,81 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
57
81
  detectEditorType(value, editorTypes),
58
82
  );
59
83
 
60
- // Track if this is the initial mount to avoid re-detecting on every value change
61
- const isInitialMount = React.useRef(true);
84
+ // Latched once the field has had non-empty content at least once for
85
+ // this mount. Used by the seed effects below to decide whether to
86
+ // (re-)install the starter template.
87
+ const hasSeededRef = React.useRef(false);
88
+
89
+ // React reuses this `MultiTypeEditorField` instance across collector
90
+ // switches when its parent `FormField` keeps the same key (e.g. both
91
+ // collectors have a property called `script`). The `useState`
92
+ // initializer above only runs on the first mount, so without this
93
+ // effect a switch from a shell-only collector to a typescript-only
94
+ // collector leaves `selectedType` stuck on "shell" — Monaco then
95
+ // tokenises the new collector's TS content as shell (no
96
+ // highlighting), and the reverse direction tokenises shell content as
97
+ // TS (nonsense "Cannot find name" errors).
98
+ //
99
+ // Whenever `editorTypes` changes and the current selection is no
100
+ // longer offered, re-detect from the new value. We deliberately do
101
+ // NOT reset on every `value` change — that would clobber the user's
102
+ // manual dropdown choice mid-edit.
62
103
  React.useEffect(() => {
63
- if (isInitialMount.current) {
64
- isInitialMount.current = false;
65
- return;
104
+ if (!editorTypes.includes(selectedType)) {
105
+ const next = detectEditorType(value, editorTypes);
106
+ setSelectedType(next);
107
+ // Reset the seed latch too — this is effectively a fresh field
108
+ // for a different collector, and the new language's starter
109
+ // template should be eligible again.
110
+ hasSeededRef.current = false;
111
+ }
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only re-derive when the offered types change
113
+ }, [editorTypes]);
114
+
115
+ // Seed an empty field with the starter template for its detected
116
+ // language so users see a working example instead of a blank canvas.
117
+ //
118
+ // Why this isn't just `useEffect(..., [])`:
119
+ // React fires CHILD effects before PARENT effects. `DynamicForm`'s
120
+ // own init effect (which applies schema defaults) runs AFTER this one
121
+ // and, on initial mount, calls `onChange(defaults)` — which clobbers
122
+ // a freshly-seeded `script: starter` back to `script: ""` because the
123
+ // defaults effect's closure-captured `value` was the original `{}`.
124
+ // A naive `[]`-dep seed would therefore appear to do nothing.
125
+ //
126
+ // Two-effect design fixes this without depending on cross-component
127
+ // effect ordering:
128
+ // 1. The "observed" effect (declared FIRST) flips `hasSeededRef` to
129
+ // true the moment we see non-empty content in `value` — whether
130
+ // that came from our seed surviving, a parent default landing
131
+ // with content, or the user typing.
132
+ // 2. The "seed" effect re-runs on every value/starter change and
133
+ // installs the starter while `hasSeededRef` is still false. If
134
+ // the parent clobbers us back to "", we just re-seed on the
135
+ // next pass — until the value sticks and effect (1) latches.
136
+ //
137
+ // The latch also means we DON'T re-seed if the user deliberately
138
+ // deletes their content later, or on any subsequent refetch / parent
139
+ // re-render: once non-empty has been observed at least once, the
140
+ // field is the user's territory.
141
+ React.useEffect(() => {
142
+ if (!hasSeededRef.current && (value ?? "").trim().length > 0) {
143
+ hasSeededRef.current = true;
66
144
  }
67
- // Don't re-detect type when value changes after initial mount
68
145
  }, [value]);
69
146
 
147
+ React.useEffect(() => {
148
+ if (hasSeededRef.current) return;
149
+ const starter = pickStarterForEmptyField({
150
+ value,
151
+ selectedType,
152
+ starterTemplates,
153
+ });
154
+ if (starter !== undefined) {
155
+ onChange(starter);
156
+ }
157
+ }, [value, selectedType, starterTemplates, onChange]);
158
+
70
159
  const handleTypeChange = (newType: EditorType) => {
71
160
  setSelectedType(newType);
72
161
 
@@ -76,6 +165,19 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
76
165
  return;
77
166
  }
78
167
 
168
+ // If the field is empty after the switch and we have a starter template
169
+ // for the new language, seed it. (Switching languages on a non-empty
170
+ // field preserves the existing content, just like before.)
171
+ const starter = pickStarterForEmptyField({
172
+ value,
173
+ selectedType: newType,
174
+ starterTemplates,
175
+ });
176
+ if (starter !== undefined) {
177
+ onChange(starter);
178
+ return;
179
+ }
180
+
79
181
  // When switching to formdata
80
182
  if (newType === "formdata") {
81
183
  if (!value || value.trim() === "") {
@@ -242,6 +344,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
242
344
  language="javascript"
243
345
  minHeight="150px"
244
346
  templateProperties={templateProperties}
347
+ typeDefinitions={typeDefinitions}
245
348
  />
246
349
  )}
247
350
 
@@ -253,6 +356,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
253
356
  language="typescript"
254
357
  minHeight="150px"
255
358
  templateProperties={templateProperties}
359
+ typeDefinitions={typeDefinitions}
256
360
  />
257
361
  )}
258
362
 
@@ -264,6 +368,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
264
368
  language="shell"
265
369
  minHeight="150px"
266
370
  templateProperties={templateProperties}
371
+ shellEnvVars={shellEnvVars}
267
372
  />
268
373
  )}
269
374
  </div>
@@ -13,6 +13,8 @@ export type {
13
13
  OptionsResolver,
14
14
  ResolverOption,
15
15
  EditorType,
16
+ ShellEnvVar,
17
+ EditorStarterTemplates,
16
18
  } from "./types";
17
19
 
18
20
  // Utility functions
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { pickStarterForEmptyField } from "./starterTemplateSelector";
3
+ import type { EditorStarterTemplates } from "./types";
4
+
5
+ /**
6
+ * Regression suite for the starter-template seeding behaviour in
7
+ * `MultiTypeEditorField`.
8
+ *
9
+ * The previous implementation gated seeding behind an `isInitialMount`
10
+ * ref that a sibling effect with `[value]` deps flipped to false BEFORE
11
+ * the seed effect ran — so seeding silently no-op'd on every mount and
12
+ * users always saw a blank canvas. The refactored seed effect delegates
13
+ * to this pure function; testing it here proves the decision is correct
14
+ * regardless of the React effect ordering trap.
15
+ */
16
+
17
+ const STARTERS: EditorStarterTemplates = {
18
+ typescript: "// starter for TypeScript",
19
+ shell: "#!/bin/sh\necho starter for shell",
20
+ };
21
+
22
+ describe("pickStarterForEmptyField", () => {
23
+ it("returns the starter for the selected language when the field is empty", () => {
24
+ expect(
25
+ pickStarterForEmptyField({
26
+ value: "",
27
+ selectedType: "typescript",
28
+ starterTemplates: STARTERS,
29
+ }),
30
+ ).toBe(STARTERS.typescript);
31
+ });
32
+
33
+ it("returns the starter when the value is whitespace-only", () => {
34
+ // The original bug let trailing whitespace block the seed; explicit
35
+ // coverage so we don't regress.
36
+ expect(
37
+ pickStarterForEmptyField({
38
+ value: " \n\t ",
39
+ selectedType: "typescript",
40
+ starterTemplates: STARTERS,
41
+ }),
42
+ ).toBe(STARTERS.typescript);
43
+ });
44
+
45
+ it("returns undefined when the field already has content (don't clobber)", () => {
46
+ expect(
47
+ pickStarterForEmptyField({
48
+ value: "// user's existing code",
49
+ selectedType: "typescript",
50
+ starterTemplates: STARTERS,
51
+ }),
52
+ ).toBeUndefined();
53
+ });
54
+
55
+ it("returns undefined when value is undefined and no starter is defined for the selected language", () => {
56
+ expect(
57
+ pickStarterForEmptyField({
58
+ value: undefined,
59
+ selectedType: "json",
60
+ starterTemplates: STARTERS,
61
+ }),
62
+ ).toBeUndefined();
63
+ });
64
+
65
+ it("returns undefined when no `starterTemplates` are provided at all", () => {
66
+ expect(
67
+ pickStarterForEmptyField({
68
+ value: "",
69
+ selectedType: "typescript",
70
+ }),
71
+ ).toBeUndefined();
72
+ });
73
+
74
+ it("returns the shell starter when shell is selected", () => {
75
+ expect(
76
+ pickStarterForEmptyField({
77
+ value: undefined,
78
+ selectedType: "shell",
79
+ starterTemplates: STARTERS,
80
+ }),
81
+ ).toBe(STARTERS.shell);
82
+ });
83
+
84
+ it("treats an empty-string starter as 'no starter available'", () => {
85
+ // We never want to install a literal empty string on top of an
86
+ // already-empty field; that would just look like the seed silently
87
+ // did nothing.
88
+ expect(
89
+ pickStarterForEmptyField({
90
+ value: "",
91
+ selectedType: "typescript",
92
+ starterTemplates: { typescript: "" },
93
+ }),
94
+ ).toBeUndefined();
95
+ });
96
+ });
@@ -0,0 +1,32 @@
1
+ import type { EditorStarterTemplates, EditorType } from "./types";
2
+
3
+ /**
4
+ * Pure decision function powering the "seed empty editor with a starter
5
+ * template" behaviour in `MultiTypeEditorField`. Returns the starter
6
+ * content to install, or `undefined` if the field shouldn't be touched.
7
+ *
8
+ * Used both on first mount and when the user switches editor language on
9
+ * a still-empty field, so a single source of truth governs both paths
10
+ * (and a single test suite covers them).
11
+ *
12
+ * Returns `undefined` when:
13
+ * - the field already has non-whitespace content (don't clobber user input)
14
+ * - no `starterTemplates` were provided
15
+ * - no starter exists for the currently-selected language
16
+ * - the matching starter is the empty string
17
+ */
18
+ export function pickStarterForEmptyField({
19
+ value,
20
+ selectedType,
21
+ starterTemplates,
22
+ }: {
23
+ value: string | undefined;
24
+ selectedType: EditorType;
25
+ starterTemplates?: EditorStarterTemplates;
26
+ }): string | undefined {
27
+ const trimmed = (value ?? "").trim();
28
+ if (trimmed.length > 0) return undefined;
29
+ const starter = starterTemplates?.[selectedType];
30
+ if (!starter || starter.length === 0) return undefined;
31
+ return starter;
32
+ }
@@ -1,8 +1,11 @@
1
- import type { TemplateProperty } from "../CodeEditor";
1
+ import type { TemplateProperty, ShellEnvVar } from "../CodeEditor";
2
2
  import type { EditorType } from "@checkstack/common";
3
3
 
4
4
  // Re-export types used by multi-type editor
5
5
  export type { EditorType } from "./utils";
6
+ // Re-export `ShellEnvVar` so DynamicForm consumers don't have to import
7
+ // from two paths. The canonical definition lives in `../CodeEditor`.
8
+ export type { ShellEnvVar } from "../CodeEditor";
6
9
  import type {
7
10
  JsonSchemaPropertyCore,
8
11
  JsonSchemaBase,
@@ -40,6 +43,13 @@ export type OptionsResolver = (
40
43
  */
41
44
  export type JsonSchema = JsonSchemaBase<JsonSchemaProperty>;
42
45
 
46
+ /**
47
+ * Default starter templates per editor language. Used to populate empty
48
+ * multi-type editor fields so users see a working example instead of a
49
+ * blank canvas. Keyed by `EditorType` (e.g. "typescript", "shell").
50
+ */
51
+ export type EditorStarterTemplates = Partial<Record<EditorType, string>>;
52
+
43
53
  export interface DynamicFormProps {
44
54
  schema: JsonSchema;
45
55
  value: Record<string, unknown>;
@@ -59,6 +69,25 @@ export interface DynamicFormProps {
59
69
  * When provided, fields with x-editor-types get {{ autocomplete suggestions.
60
70
  */
61
71
  templateProperties?: TemplateProperty[];
72
+ /**
73
+ * Optional TypeScript declarations to inject into Monaco for `typescript`
74
+ * or `javascript` editor-type fields. Typically built from a schema via
75
+ * `generateTypeDefinitions()` so users get autocomplete + type errors
76
+ * against the runtime context they'll see.
77
+ */
78
+ typeDefinitions?: string;
79
+ /**
80
+ * Optional list of environment-variable names that are available to
81
+ * `shell` editor-type fields. When provided, Monaco autocompletes them
82
+ * after `$` and `${`. Use this to surface platform-injected vars like
83
+ * `EVENT_ID`, `PAYLOAD_*` etc. so users don't have to remember the names.
84
+ */
85
+ shellEnvVars?: ShellEnvVar[];
86
+ /**
87
+ * Optional initial content per editor language, used to populate empty
88
+ * fields with a working example. Keyed by `EditorType`.
89
+ */
90
+ starterTemplates?: EditorStarterTemplates;
62
91
  }
63
92
 
64
93
  /** Props for the FormField component */
@@ -71,10 +100,14 @@ export interface FormFieldProps {
71
100
  formValues: Record<string, unknown>;
72
101
  optionsResolvers?: Record<string, OptionsResolver>;
73
102
  templateProperties?: TemplateProperty[];
103
+ typeDefinitions?: string;
104
+ shellEnvVars?: ShellEnvVar[];
105
+ starterTemplates?: EditorStarterTemplates;
74
106
  /** Callback when value changes. Omit val to clear the field. */
75
107
  onChange: (val?: unknown) => void;
76
108
  }
77
109
 
110
+
78
111
  /** Props for the DynamicOptionsField component */
79
112
  export interface DynamicOptionsFieldProps {
80
113
  id: string;
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { shouldInitForKey } from "./useInitOnceForKey";
3
+
4
+ /**
5
+ * Regression suite for the form-preservation bug in the healthcheck editor.
6
+ *
7
+ * Before this hook existed, the page had:
8
+ *
9
+ * useEffect(() => {
10
+ * if (existingConfig) setFormState({ … });
11
+ * }, [existingConfig]);
12
+ *
13
+ * That fires on every refetch — including the refetches triggered by the
14
+ * realtime `HEALTH_CHECK_RUN_COMPLETED` signal — so the user's in-progress
15
+ * edits got wiped every time a healthcheck run completed anywhere on the
16
+ * platform.
17
+ *
18
+ * The hook delegates its decision to the pure `shouldInitForKey` function
19
+ * tested below. The hook is a thin React wrapper around two refs and a
20
+ * `useEffect` — testing the decision function gives full coverage of the
21
+ * logic without needing a DOM.
22
+ */
23
+
24
+ describe("shouldInitForKey", () => {
25
+ it("returns true the first time value+key are defined and no key has been initialised", () => {
26
+ expect(
27
+ shouldInitForKey({
28
+ value: { name: "A" },
29
+ key: "id-1",
30
+ initialisedKey: undefined,
31
+ }),
32
+ ).toBe(true);
33
+ });
34
+
35
+ it("returns false when value is undefined (query still loading)", () => {
36
+ expect(
37
+ shouldInitForKey({
38
+ value: undefined,
39
+ key: "id-1",
40
+ initialisedKey: undefined,
41
+ }),
42
+ ).toBe(false);
43
+ });
44
+
45
+ it("returns false when value is null", () => {
46
+ expect(
47
+ shouldInitForKey({
48
+ value: null,
49
+ key: "id-1",
50
+ initialisedKey: undefined,
51
+ }),
52
+ ).toBe(false);
53
+ });
54
+
55
+ it("returns false when key is undefined (no discriminator yet)", () => {
56
+ expect(
57
+ shouldInitForKey({
58
+ value: { name: "A" },
59
+ key: undefined,
60
+ initialisedKey: undefined,
61
+ }),
62
+ ).toBe(false);
63
+ });
64
+
65
+ it("returns false when key is null (record-not-found path)", () => {
66
+ expect(
67
+ shouldInitForKey({
68
+ value: { name: "A" },
69
+ key: null,
70
+ initialisedKey: undefined,
71
+ }),
72
+ ).toBe(false);
73
+ });
74
+
75
+ it("returns false on a background refetch (same key already initialised)", () => {
76
+ // THE regression case: react-query refetched the same record after an
77
+ // invalidation, handing us a new `value` object reference. The
78
+ // `initialisedKey` already matches the new key, so we must not re-fire.
79
+ expect(
80
+ shouldInitForKey({
81
+ value: { name: "A (refetched)" },
82
+ key: "id-1",
83
+ initialisedKey: "id-1",
84
+ }),
85
+ ).toBe(false);
86
+ });
87
+
88
+ it("returns true when the key changes (user navigated to a different record)", () => {
89
+ expect(
90
+ shouldInitForKey({
91
+ value: { name: "B" },
92
+ key: "id-2",
93
+ initialisedKey: "id-1",
94
+ }),
95
+ ).toBe(true);
96
+ });
97
+
98
+ it("returns true when we're returning to a previously-seen key from a different one", () => {
99
+ // The hook only stores the LAST initialised key, so flipping between
100
+ // two records re-initialises each time. That's the correct behaviour:
101
+ // each visit shows fresh server data.
102
+ expect(
103
+ shouldInitForKey({
104
+ value: { name: "A" },
105
+ key: "id-1",
106
+ initialisedKey: "id-2",
107
+ }),
108
+ ).toBe(true);
109
+ });
110
+
111
+ it("treats numeric keys correctly (different number → re-init)", () => {
112
+ expect(
113
+ shouldInitForKey({ value: "any", key: 1, initialisedKey: 1 }),
114
+ ).toBe(false);
115
+ expect(
116
+ shouldInitForKey({ value: "any", key: 2, initialisedKey: 1 }),
117
+ ).toBe(true);
118
+ });
119
+
120
+ it("treats string vs number keys as different (no implicit coercion)", () => {
121
+ // If a caller starts passing IDs as numbers after passing strings (or
122
+ // vice versa) we'd rather re-init than miss an update; strict !== handles it.
123
+ expect(
124
+ shouldInitForKey({ value: "any", key: "1", initialisedKey: 1 }),
125
+ ).toBe(true);
126
+ });
127
+ });
@@ -0,0 +1,87 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Pure decision function powering {@link useInitOnceForKey}. Extracted so
5
+ * it can be unit-tested without a DOM (the hook itself wraps this with
6
+ * a `useRef` + `useEffect`).
7
+ *
8
+ * Returns `true` iff the caller should run their initialiser:
9
+ *
10
+ * - `value` is defined (the query has finished loading), AND
11
+ * - `key` is defined (we have a discriminator to track init-per-record),
12
+ * AND
13
+ * - we haven't yet initialised for this key (i.e. `initialisedKey !== key`).
14
+ *
15
+ * Background refetches of the same record produce a new `value` reference
16
+ * but the same `key`, so the function returns `false` for them — that's
17
+ * the whole point.
18
+ */
19
+ export function shouldInitForKey({
20
+ value,
21
+ key,
22
+ initialisedKey,
23
+ }: {
24
+ value: unknown;
25
+ key: string | number | null | undefined;
26
+ initialisedKey: string | number | null | undefined;
27
+ }): boolean {
28
+ if (value === undefined || value === null) return false;
29
+ if (key === undefined || key === null) return false;
30
+ return initialisedKey !== key;
31
+ }
32
+
33
+ /**
34
+ * Run a one-shot initialiser exactly once per `key`, ignoring subsequent
35
+ * `value` changes that keep the same key.
36
+ *
37
+ * Built for forms that need to seed local state from a react-query result
38
+ * but **must not** reset that state when the query refetches in the
39
+ * background. The canonical use case is the healthcheck editor: a realtime
40
+ * `HEALTH_CHECK_RUN_COMPLETED` signal invalidates the configuration query
41
+ * on every run, which would otherwise wipe the user's in-progress edits
42
+ * via a naive `useEffect([data], () => setState(data))`.
43
+ *
44
+ * Behaviour:
45
+ * - Calls `onInit(value)` the first time `value` is defined for a given
46
+ * `key`.
47
+ * - **Does NOT** call it again if `value` changes but `key` stays the
48
+ * same. Background refetches keep the same key (= the same record's
49
+ * primary id) and therefore don't re-run the initialiser.
50
+ * - **Does** call it again when `key` changes — e.g. when the user
51
+ * navigates to a different record without unmounting the page.
52
+ * - Skips initialisation entirely while either `value` or `key` is
53
+ * `undefined`/`null`.
54
+ *
55
+ * `onInit` is read from a ref so callers can safely pass a fresh closure
56
+ * each render without re-firing the effect.
57
+ *
58
+ * @example
59
+ * useInitOnceForKey(existingConfig, existingConfig?.id, (config) => {
60
+ * setFormState({
61
+ * name: config.name,
62
+ * collectors: config.collectors ?? [],
63
+ * });
64
+ * });
65
+ */
66
+ export function useInitOnceForKey<T>(
67
+ value: T | undefined | null,
68
+ key: string | number | null | undefined,
69
+ onInit: (value: T) => void,
70
+ ): void {
71
+ const initialisedKeyRef = useRef<string | number | null | undefined>(undefined);
72
+ const onInitRef = useRef(onInit);
73
+
74
+ // Keep the latest callback in a ref so a fresh closure each render doesn't
75
+ // re-trigger the effect; only `value` and `key` should drive it.
76
+ useEffect(() => {
77
+ onInitRef.current = onInit;
78
+ });
79
+
80
+ useEffect(() => {
81
+ if (!shouldInitForKey({ value, key, initialisedKey: initialisedKeyRef.current })) {
82
+ return;
83
+ }
84
+ initialisedKeyRef.current = key;
85
+ onInitRef.current(value as T);
86
+ }, [value, key]);
87
+ }
package/src/index.ts CHANGED
@@ -63,3 +63,4 @@ export * from "./components/MetricTile";
63
63
  export * from "./components/Sheet";
64
64
  export * from "./components/Popover";
65
65
  export * from "./hooks/useIsMobile";
66
+ export * from "./hooks/useInitOnceForKey";
package/tsconfig.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "extends": "@checkstack/tsconfig/frontend.json",
3
3
  "include": [
4
- "src"
4
+ "src",
5
+ "src/components/CodeEditor/generated/stdlib-types.json",
6
+ "scripts"
5
7
  ],
6
8
  "references": [
7
9
  {