Skip to content
25 changes: 25 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1715,9 +1715,34 @@
in favor of their own tsconfig island; the engine is bundled to an
injectable IIFE string by the fork:design-mode-engine Vite plugin as
virtual:fork-design-mode-engine.

Two accuracy rules govern what a change request is allowed to assert, both
added after a request cost more time than doing the edit by hand. First, a
css bullet may only say "change `px-2.5` -> `px-1`" once cssOrigin.ts has
PROVED that class is the lever, by removing it and re-measuring while the
element shows its original cascade; when a stylesheet rule outranks the
utility instead, the bullet names that rule and its file (Vite's
data-vite-dev-id is what still carries the authored filename) and drops the
utility suggestion, because editing an overridden class is a silent no-op.
Second, a source location is only forwarded when react-grab reports the
frame as symbolicated: an unsymbolicated frame carries GENERATED
coordinates, which look authoritative and point into a line the authored
file may not even have. Rejection costs the LINE, not the file:
`data-t3-source-file` still carries the authored path (react-grab reads it
off the fiber's module, so only the position inside it needed
symbolication) and `data-t3-component` carries the fiber's component name,
which together read as "Rendered by `<X>` in path (line not resolvable)" —
usually the faster address anyway. Relatedly, the NO_PREVIEW guardrail must
not promise upstream's automatic verification while this fork has not
vendored client/verifier.ts.
tier: 4
files:
- apps/web/src/custom/designMode/protocol.ts
# Outside engine/ on purpose: like protocol.ts it crosses the TS-island fence
# (the engine tsconfig includes it by path) so the web project can type-check
# and unit-test it, which files under engine/ cannot be.
- apps/web/src/custom/designMode/cssOrigin.ts
- apps/web/src/__fork_guards__/forkDesignModeCssOrigin.test.ts
- apps/web/src/custom/designMode/designModeStore.ts
- apps/web/src/custom/designMode/designModeBridge.ts
- apps/web/src/custom/designMode/designChangeDraftStore.ts
Expand Down
78 changes: 75 additions & 3 deletions apps/desktop/src/preview/DesignSourceResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { normalizeResolvedSource } from "./DesignSourceResult.ts";
import { describeResolvedSource, normalizeResolvedSource } from "./DesignSourceResult.ts";

const VALID = {
filePath: "/src/components/Button.tsx",
Expand All @@ -21,13 +21,27 @@ describe("normalizeResolvedSource", () => {
expect(normalizeResolvedSource({ ...VALID, columnNumber: 0 })?.column).toBe(1);
});

it("carries only the source location — extra react-grab context never crosses", () => {
it("carries the source location and component name — nothing else crosses", () => {
const result = normalizeResolvedSource({
...VALID,
componentName: "SubmitButton",
selector: "button.primary",
snippet: "<button/>",
} as typeof VALID);
expect(result).toEqual({ file: VALID.filePath, line: 12, column: 4 });
expect(result).toEqual({
file: VALID.filePath,
line: 12,
column: 4,
componentName: "SubmitButton",
});
});

it("drops a component name that is not identifier-shaped", () => {
expect(normalizeResolvedSource({ ...VALID, componentName: "a b\nc" } as typeof VALID)).toEqual({
file: VALID.filePath,
line: 12,
column: 4,
});
});

it("rejects contexts without a usable source location", () => {
Expand All @@ -49,4 +63,62 @@ describe("normalizeResolvedSource", () => {
expect(normalizeResolvedSource({ ...VALID, filePath: "/src/\tApp.tsx" })).toBeNull();
expect(normalizeResolvedSource({ ...VALID, filePath: "/src/App.tsx\u007f" })).toBeNull();
});

// The failure this guards: a 71-line ComposerControl.tsx reported "at line 135" — a real
// position in the SERVED module, which reads as authoritative and sends the agent hunting.
it("rejects a location react-grab could not symbolicate", () => {
const stack = [{ fileName: VALID.filePath, isSymbolicated: false }];
expect(normalizeResolvedSource({ ...VALID, stack } as typeof VALID)).toBeNull();
});

it("judges symbolication by the frame naming the reported file", () => {
const stack = [
{ fileName: "/src/other.tsx", isSymbolicated: false },
{ fileName: VALID.filePath, isSymbolicated: true },
];
expect(normalizeResolvedSource({ ...VALID, stack } as typeof VALID)?.line).toBe(12);
});

it("treats a missing flag or missing stack as symbolicated", () => {
expect(normalizeResolvedSource({ ...VALID, stack: [] } as typeof VALID)?.line).toBe(12);
expect(
normalizeResolvedSource({ ...VALID, stack: [{ fileName: VALID.filePath }] } as typeof VALID)
?.line,
).toBe(12);
});
});

describe("describeResolvedSource", () => {
it("returns the full location when there is one", () => {
expect(describeResolvedSource({ ...VALID, componentName: "Btn" } as typeof VALID)).toEqual({
file: VALID.filePath,
line: 12,
column: 4,
componentName: "Btn",
});
});

// A rejected location loses the LINE, not the file: react-grab reads the path off the
// fiber's module, and only the position inside it needed symbolication.
it("keeps the component name and the file when the line is rejected", () => {
const stack = [{ fileName: VALID.filePath, isSymbolicated: false }];
expect(
describeResolvedSource({ ...VALID, stack, componentName: "Btn" } as typeof VALID),
).toEqual({ componentName: "Btn", file: VALID.filePath });
});

it("never emits a line or column alongside a rejected location", () => {
const stack = [{ fileName: VALID.filePath, isSymbolicated: false }];
const result = describeResolvedSource({ ...VALID, stack } as typeof VALID);
expect(result).toEqual({ file: VALID.filePath });
expect(result).not.toHaveProperty("line");
expect(result).not.toHaveProperty("column");
});

it("returns null when nothing usable survives", () => {
expect(describeResolvedSource(null)).toBeNull();
expect(describeResolvedSource({})).toBeNull();
const stack = [{ fileName: "/a.tsx", isSymbolicated: false }];
expect(describeResolvedSource({ filePath: "/src/App.tsx\u0000", stack } as never)).toBeNull();
});
});
15 changes: 10 additions & 5 deletions apps/desktop/src/preview/DesignSourceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ import { getElementContext } from "react-grab/primitives";

import {
DESIGN_SOURCE_RESOLVER_GLOBAL,
normalizeResolvedSource,
describeResolvedSource,
type DesignSourceResult,
} from "./DesignSourceResult.ts";

/** What `resolve` actually puts on the wire: a full location, or — when react-grab could not
* symbolicate — just the component name, which the engine uses to label an otherwise
* selector-addressed element. Reading only the location fields yields the original contract. */
type ResolvedPayload = Partial<DesignSourceResult> & { componentName?: string };

/** react-grab resolution symbolicates through source maps — cap concurrent work so a
* scrubbing cursor can't queue dozens of expensive lookups at once. */
const MAX_CONCURRENT = 2;
Expand Down Expand Up @@ -52,15 +57,15 @@ function releaseSlot(): void {
* an element resolved before React mounted its dev metadata (hydration, a lazy chunk)
* can succeed on a later ask instead of staying selector-only forever (PR #54 review).
* The TTL also bounds retry cost: at most one react-grab attempt per element per TTL. */
const resolutionCache = new WeakMap<Element, Promise<DesignSourceResult | null>>();
const resolutionCache = new WeakMap<Element, Promise<ResolvedPayload | null>>();

const NULL_RESULT_TTL_MS = 5000;

async function resolveElement(element: Element): Promise<DesignSourceResult | null> {
async function resolveElement(element: Element): Promise<ResolvedPayload | null> {
await acquireSlot();
try {
const context = await getElementContext(element);
const normalized = normalizeResolvedSource(context);
const normalized = describeResolvedSource(context);
// Recheck after the await — an element replaced mid-resolution must not hand the
// engine a location for a node that no longer exists.
if (!normalized || !element.isConnected || element.ownerDocument !== document) return null;
Expand All @@ -72,7 +77,7 @@ async function resolveElement(element: Element): Promise<DesignSourceResult | nu
}
}

function resolve(element: unknown): Promise<DesignSourceResult | null> {
function resolve(element: unknown): Promise<ResolvedPayload | null> {
if (!(element instanceof Element) || element.ownerDocument !== document || !element.isConnected) {
return Promise.resolve(null);
}
Expand Down
91 changes: 84 additions & 7 deletions apps/desktop/src/preview/DesignSourceResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@ export const DESIGN_SOURCE_RESOLVER_GLOBAL = "__T3_DESIGN_SOURCE_RESOLVER_V1__";
const MAX_FILE_LENGTH = 4096;

/** Exactly what the engine consumes — it synthesizes a canonical `file:line:col` tag and
* derives labels/selectors itself, so nothing else crosses the bridge (PR #54 review). */
* derives labels/selectors itself, so nothing else crosses the bridge (PR #54 review).
*
* `componentName` rides alongside because it survives the one failure the location does not:
* react-grab reads the fiber's position in the SERVED module and only maps it back to the
* authored file when it can symbolicate. Unsymbolicated, `line` is a post-transform line
* number — it looks valid, points into a file the agent then cannot make sense of (a 71-line
* source "at line 135"), and costs more time than no location at all. The component name is
* derived from the fiber itself, so it stays correct either way. */
export interface DesignSourceResult {
file: string;
line: number;
column: number;
componentName?: string;
}

/**
Expand All @@ -32,24 +40,93 @@ export function normalizeResolvedSource(
filePath?: unknown;
lineNumber?: unknown;
columnNumber?: unknown;
componentName?: unknown;
stack?: unknown;
} | null,
): DesignSourceResult | null {
if (!value || typeof value !== "object") return null;
const { filePath, lineNumber, columnNumber } = value;
if (typeof filePath !== "string" || filePath.length === 0 || filePath.length > MAX_FILE_LENGTH) {
return null;
}
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(filePath)) return null;
// An unsymbolicated frame reports GENERATED coordinates — the line in the module Vite
// served, not in the file the agent will open. Rejecting the location outright is
// deliberate: the engine's fallback (selector + component name) beats a confident pointer
// at the wrong line of the right file, which reads as authoritative and is not.
if (!isSymbolicated(value.stack, filePath)) return null;
const file = normalizeFilePath(filePath);
if (file === null) return null;
if (typeof lineNumber !== "number" || !Number.isInteger(lineNumber) || lineNumber < 1) {
return null;
}
if (typeof columnNumber !== "number" || !Number.isInteger(columnNumber) || columnNumber < 0) {
return null;
}
const componentName = normalizeComponentName(value.componentName);
return {
file: filePath,
file,
line: lineNumber,
column: Math.max(1, columnNumber),
...(componentName === null ? {} : { componentName }),
};
}

/** A usable development file path, or null. Page-controlled: length-capped, and rejected
* outright when it carries control characters (a newline-bearing "path" could otherwise inject
* instruction lines into the agent's request). */
export function normalizeFilePath(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0 || value.length > MAX_FILE_LENGTH) {
return null;
}
// eslint-disable-next-line no-control-regex
return /[\u0000-\u001f\u007f]/.test(value) ? null : value;
}

/** Identifier-shaped display names only — the value is page-controlled and ends up inside the
* agent's request text. */
const COMPONENT_NAME_PATTERN = /^[A-Za-z_$][\w$.]{0,63}$/;

export function normalizeComponentName(value: unknown): string | null {
return typeof value === "string" && COMPONENT_NAME_PATTERN.test(value) ? value : null;
}

/**
* Whether react-grab mapped the reported location back to authored source.
*
* react-grab marks each stack frame with `isSymbolicated`. The frame naming `filePath` is the
* one that produced the reported line/column, so its flag is the one that counts. A missing
* flag reads as symbolicated (older react-grab builds omit it, and rejecting every location
* there would be a regression), and so does an absent stack — that is the pre-existing
* contract for hosts whose resolver hands back a bare location.
*/
export function isSymbolicated(stack: unknown, filePath: unknown): boolean {
if (!Array.isArray(stack) || stack.length === 0) return true;
const frames = stack.filter(
(frame): frame is { fileName?: unknown; isSymbolicated?: unknown } =>
typeof frame === "object" && frame !== null,
);
const match = frames.find((frame) => frame.fileName === filePath) ?? frames[0];
if (!match) return true;
return match.isSymbolicated !== false;
}

/**
* The full payload the resolver puts on the wire: the validated location when there is one,
* plus the component name, which comes off the fiber and stays correct even when the location
* is rejected. Additive by design — an engine reading only `file`/`line`/`column` sees exactly
* what it saw before, and a host that predates this still satisfies that engine.
*/
export function describeResolvedSource(
value: Parameters<typeof normalizeResolvedSource>[0],
): (Partial<DesignSourceResult> & { componentName?: string }) | null {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const source = normalizeResolvedSource(value);
if (source) return source;
// The line was untrustworthy; the FILE was not. react-grab reads the path off the fiber's
// module, which is the authored path either way — only the position inside it needs
// symbolication. Forwarding it (explicitly without line/column) keeps the single most
// useful part of a rejected location instead of discarding the whole thing.
const componentName = normalizeComponentName(value?.componentName);
const file = normalizeFilePath(value?.filePath);
if (componentName === null && file === null) return null;
return {
...(componentName === null ? {} : { componentName }),
...(file === null ? {} : { file }),
};
}
Loading
Loading