refactor(editor): PinnedDataSection — useId select, side-aware placeholder, exhaustive-deps docs

This commit is contained in:
Claude
2026-08-30 14:37:19 +00:00
parent 3842eca8cd
commit 432782aeb1

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useId, useState } from "react";
import { useExecution, useExecutions } from "@/api/hooks"; import { useExecution, useExecutions } from "@/api/hooks";
import type { NodeExecutionDto } from "@/api/types"; import type { NodeExecutionDto } from "@/api/types";
@@ -17,17 +17,21 @@ export function PinnedDataSection({
const [text, setText] = useState<string>(() => stringifyOrEmpty(value)); const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadEid, setLoadEid] = useState<string>(""); const [loadEid, setLoadEid] = useState<string>("");
const selectId = useId();
// If the parent's value changes while we're not editing (e.g. after a // 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 // 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 // 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. // 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(() => { useEffect(() => {
const parsedCurrent = safeParse(text); const parsedCurrent = safeParse(text);
if (parsedCurrent === undefined) return; // parse-error state — don't overwrite if (parsedCurrent === undefined) return; // parse-error state — don't overwrite
if (canonicalize(parsedCurrent) === canonicalize(value)) return; if (canonicalize(parsedCurrent) === canonicalize(value)) return;
setText(stringifyOrEmpty(value)); setText(stringifyOrEmpty(value));
}, [value]); // deliberately not depending on `text` }, [value]);
function commit(next: string) { function commit(next: string) {
setText(next); setText(next);
@@ -58,6 +62,15 @@ export function PinnedDataSection({
// Once the selected execution's detail arrives, splice its inputs[0] / // Once the selected execution's detail arrives, splice its inputs[0] /
// outputs[0] for our node into the textarea. // 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(() => { useEffect(() => {
if (!loadEid || !loadedExec.data) return; if (!loadEid || !loadedExec.data) return;
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId); 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"> <div className="flex items-center justify-between gap-2">
<span className="text-xs uppercase text-slate-500">{title}</span> <span className="text-xs uppercase text-slate-500">{title}</span>
<div className="flex items-center gap-1"> <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: Load from execution:
</label> </label>
<select <select
id="load-from-exec" id={selectId}
aria-label="Load from execution" aria-label="Load from execution"
value={loadEid} value={loadEid}
onChange={(e) => setLoadEid(e.target.value)} onChange={(e) => setLoadEid(e.target.value)}
@@ -105,7 +118,9 @@ export function PinnedDataSection({
aria-label={title} aria-label={title}
value={text} value={text}
onChange={(e) => commit(e.target.value)} 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} rows={10}
spellCheck={false} 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" 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); 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 { function safeParse(s: string): unknown | undefined {
const t = s.trim(); const t = s.trim();
if (t === "") return null; if (t === "") return null;
@@ -131,6 +152,10 @@ function safeParse(s: string): unknown | undefined {
} }
// Very small structural fingerprint. Sufficient for parent-vs-text equality. // 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 { function canonicalize(v: unknown): string {
return JSON.stringify(v); return JSON.stringify(v);
} }