refactor(editor): PinnedDataSection — useId select, side-aware placeholder, exhaustive-deps docs
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useExecution, useExecutions } from "@/api/hooks";
|
||||
import type { NodeExecutionDto } from "@/api/types";
|
||||
|
||||
@@ -17,17 +17,21 @@ export function PinnedDataSection({
|
||||
const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadEid, setLoadEid] = useState<string>("");
|
||||
const selectId = useId();
|
||||
|
||||
// If the parent's value changes while we're not editing (e.g. after a
|
||||
// load-from-execution or a Clear), keep the textarea in sync. We only
|
||||
// re-sync when the current text is invalid (let parse-error stand) OR the
|
||||
// current text parses to a different value than the parent's.
|
||||
// Depending on `text` here would create a self-triggering loop; we
|
||||
// rely on `safeParse`'s tri-state guard instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const parsedCurrent = safeParse(text);
|
||||
if (parsedCurrent === undefined) return; // parse-error state — don't overwrite
|
||||
if (canonicalize(parsedCurrent) === canonicalize(value)) return;
|
||||
setText(stringifyOrEmpty(value));
|
||||
}, [value]); // deliberately not depending on `text`
|
||||
}, [value]);
|
||||
|
||||
function commit(next: string) {
|
||||
setText(next);
|
||||
@@ -58,6 +62,15 @@ export function PinnedDataSection({
|
||||
|
||||
// Once the selected execution's detail arrives, splice its inputs[0] /
|
||||
// outputs[0] for our node into the textarea.
|
||||
// Deps omit `nodeId`, `side`, and `commit` deliberately:
|
||||
// - `nodeId` and `side` are effectively constant per mount (dialog owns
|
||||
// one node at a time).
|
||||
// - `commit` closes over `onChange`/`setText`/`setError` — all stable
|
||||
// setters, and we WANT the current onChange, so re-capturing per
|
||||
// render is fine.
|
||||
// - Including any of them would cause the effect to re-fire on
|
||||
// unrelated re-renders and clobber user edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!loadEid || !loadedExec.data) return;
|
||||
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId);
|
||||
@@ -75,11 +88,11 @@ export function PinnedDataSection({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs uppercase text-slate-500">{title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-xs text-slate-500" htmlFor="load-from-exec">
|
||||
<label className="text-xs text-slate-500" htmlFor={selectId}>
|
||||
Load from execution:
|
||||
</label>
|
||||
<select
|
||||
id="load-from-exec"
|
||||
id={selectId}
|
||||
aria-label="Load from execution"
|
||||
value={loadEid}
|
||||
onChange={(e) => setLoadEid(e.target.value)}
|
||||
@@ -105,7 +118,9 @@ export function PinnedDataSection({
|
||||
aria-label={title}
|
||||
value={text}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
placeholder="(none — this node runs on real upstream data)"
|
||||
placeholder={side === "outputs"
|
||||
? "(none — this node emits real data when run)"
|
||||
: "(none — this node runs on real upstream data)"}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-xs font-mono leading-5 resize-none"
|
||||
@@ -124,6 +139,12 @@ function stringifyOrEmpty(v: unknown | null): string {
|
||||
return JSON.stringify(v, null, 2);
|
||||
}
|
||||
|
||||
// Tri-state:
|
||||
// `null` → empty / whitespace-only text (semantic: "no pin")
|
||||
// `undefined` → text is non-empty but JSON.parse threw (parse-error state)
|
||||
// otherwise → the parsed value
|
||||
// The parent-sync effect distinguishes these to avoid overwriting an
|
||||
// in-progress parse error.
|
||||
function safeParse(s: string): unknown | undefined {
|
||||
const t = s.trim();
|
||||
if (t === "") return null;
|
||||
@@ -131,6 +152,10 @@ function safeParse(s: string): unknown | undefined {
|
||||
}
|
||||
|
||||
// Very small structural fingerprint. Sufficient for parent-vs-text equality.
|
||||
// Note: order-sensitive by design — same-shape objects with different key
|
||||
// insertion order will look distinct. In practice the parent hands us back
|
||||
// either the same object ref or a re-serialization of what we just parsed
|
||||
// (same key order), so this doesn't cause overwrite churn.
|
||||
function canonicalize(v: unknown): string {
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user