From 433f463d6c23ed3bab0ddb3a4b2fad0da7b5cd0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:04:46 +0000 Subject: [PATCH 1/6] feat(diagnostics): carry a source location on every finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev and observe builds keep a `loc` (`file:line:col`, root-relative — the compiler's `sourceNames` `locations` kind) in a `_loc` slot beside `_name` on owner, computed, effect, signal and store-property nodes, and on the client and server component owners through a fourth `createComponent` argument. `emitDiagnostic` resolves `DiagnosticEvent.loc` as the innermost located node on the subject's chain, the way `ownerPath` is found, and `reportDiagnostic` prints it as an `at` line after `in` — a dev-server URL in a browser so the devtools link it to Sources. `@solidjs/web`'s `effect` and `insert` take `loc` in their options, `spread` a trailing `loc`. `DEV.guideUrl(code)` is now the code's docs page (`https://v2.solidjs.com/reference/solid-js/diagnostics/`); the console footer leads with it for a human and keeps the repair skill (installed path and GitHub anchor) on a second line for an agent. `diagnostics.json` is regenerated and the skill documents the `at` line. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY --- ...iagnostics-source-locations-diagnostics.md | 5 + .../diagnostics-source-locations-signals.md | 5 + .../diagnostics-source-locations-solid.md | 5 + .../diagnostics-source-locations-web.md | 5 + documentation/solid-2.0/08-dev-diagnostics.md | 5 +- packages/diagnostics/tests/capture.test.ts | 37 +++- packages/signals/scripts/mangle-props.mjs | 8 +- packages/signals/src/core/core.ts | 15 +- packages/signals/src/core/dev.ts | 91 +++++++-- packages/signals/src/core/owner.ts | 10 +- packages/signals/src/core/types.ts | 14 ++ packages/signals/src/store/optimistic.ts | 7 +- packages/signals/src/store/projection.ts | 8 +- packages/signals/src/store/store.ts | 18 +- packages/signals/src/store/types.ts | 7 + packages/signals/tests/diagnostics.test.ts | 183 +++++++++++++++++- packages/signals/tests/dist-artifacts.test.ts | 22 ++- packages/solid/diagnostics.json | 132 ++++++------- .../skills/reactivity-diagnostics/SKILL.md | 20 +- packages/solid/skills/solid-2.0/SKILL.md | 4 +- packages/solid/src/client/component.ts | 11 +- packages/solid/src/client/core.ts | 12 +- packages/solid/src/console-footer.ts | 22 ++- packages/solid/src/server/component.ts | 18 +- packages/solid/src/server/signals.ts | 21 +- .../solid/test/cross-package-fields.spec.ts | 17 +- .../solid/test/server/console-footer.spec.ts | 19 +- .../test/server/server-diagnostics.spec.ts | 33 ++++ packages/web/src/client.ts | 27 ++- packages/web/src/render.ts | 15 +- packages/web/test/binding-names.spec.tsx | 77 +++++++- packages/web/test/performance-tracks.spec.tsx | 4 +- scripts/generate-diagnostics-json.mjs | 8 +- 33 files changed, 696 insertions(+), 189 deletions(-) create mode 100644 .changeset/diagnostics-source-locations-diagnostics.md create mode 100644 .changeset/diagnostics-source-locations-signals.md create mode 100644 .changeset/diagnostics-source-locations-solid.md create mode 100644 .changeset/diagnostics-source-locations-web.md diff --git a/.changeset/diagnostics-source-locations-diagnostics.md b/.changeset/diagnostics-source-locations-diagnostics.md new file mode 100644 index 0000000000..09ed16921f --- /dev/null +++ b/.changeset/diagnostics-source-locations-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@solidjs/diagnostics": patch +--- + +Captured findings carry `loc` (the source location the runtime resolved, `file:line:col`) as a plain event field through artifacts and the JSONL form; assertion failures print it with the rest of the offending event. diff --git a/.changeset/diagnostics-source-locations-signals.md b/.changeset/diagnostics-source-locations-signals.md new file mode 100644 index 0000000000..84f88c151b --- /dev/null +++ b/.changeset/diagnostics-source-locations-signals.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Diagnostics carry a source location. `NodeOptions.loc` and `StoreOptions.loc` (`file:line:col`, root-relative — written by the compiler's `sourceNames` `locations` kind, not by hand) land in a `_loc` slot beside `_name` on the observe/dev node literals; store property nodes carry their store's. `DiagnosticEvent.loc` is the innermost located node on the subject's chain (its own `loc`, else the first up the owners — a signal through its registering owner), found in `emitDiagnostic` the way `ownerPath` is; a host event that sets it keeps it. `reportDiagnostic` prints it as an `at` line after the `in` line — `at src/App.tsx:18:3`, or in a browser `at http://localhost:5173/src/App.tsx:18:3`, which the devtools link to the Sources panel. `DEV.guideUrl(code)` now returns the code's page on the docs site (`https://v2.solidjs.com/reference/solid-js/diagnostics/`). diff --git a/.changeset/diagnostics-source-locations-solid.md b/.changeset/diagnostics-source-locations-solid.md new file mode 100644 index 0000000000..09855d54a1 --- /dev/null +++ b/.changeset/diagnostics-source-locations-solid.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +`createComponent(Comp, props, name?, loc?)` takes the JSX site the compiler emits beside the tag (`"src/App.tsx:12:5"`); the dev and observe component owner carries it on both the client and the server, so a finding raised inside a component reports `at src/App.tsx:12:5` (and `event.loc`) when nothing closer was located. The console footer is now two lines: the code's docs page for a human (`what it means and how to fix it: https://v2.solidjs.com/reference/solid-js/diagnostics/`), then the repair skill for an agent (installed path and GitHub URL, anchored to the code's section). `diagnostics.json` links each code to its docs page, and the `reactivity-diagnostics` skill documents the `at` line. diff --git a/.changeset/diagnostics-source-locations-web.md b/.changeset/diagnostics-source-locations-web.md new file mode 100644 index 0000000000..9a78086d41 --- /dev/null +++ b/.changeset/diagnostics-source-locations-web.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +`effect` and `insert` take `loc` beside `name` in their options, and `spread` a trailing `loc` after its tag — the source site the compiler emits under `sourceNames` `locations` — so a diagnostic about a compiled binding reports `at src/App.tsx:14:7` and carries `event.loc`. Dev and observe builds keep it on the node; production ignores it. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 5d1ecffe21..d109065bc3 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -14,8 +14,9 @@ Every console report is one entry built for a human to act on: - The message, with the code in brackets and the repair in the text. - An `in` line naming the owners enclosing the subject, root first — component roots as ``, computations by their `name` option or the `effect`/`computed` default (`in › › › effect`) — a compiled binding effect reads as what it writes (`span.textContent`, `div.class:active`, a hole `div.children`) when the JSX compiler's `sourceNames.bindings` is on (both `@solidjs/babel-plugin` and `@solidjs/compiler` take the same `sourceNames` option, on by default in dev builds alongside `components`); a primitive reads as the identifier it was declared as (`count`, `doubled`, `todos.title`, `createCounter.value` inside a composed primitive) when `@solidjs/compiler`'s standalone `transformSourceNames` pass has run — primitive naming is not a JSX-transform feature: the build tool applies that pass to every module, `.ts`/`.js` and JSX alike, independently of which JSX compiler handles the file (the Vite plugin's `sourceNames.primitives`, on by default in its dev and `observe` postures; solid-vite-plugin #371) — or as its `name` option otherwise. The same chain is `event.ownerPath` on the structured event. A component's name is the tag as written in source when the JSX compiler's `sourceNames.components` is on (`createComponent(Home, props, "Home")` — on by default in dev builds; the Vite plugin also enables it for the `observe` posture, so minified observe builds still read ``), otherwise the function's `.name`, which a minifier rewrites and a `lazy()` wrapper hides. Components a library invokes by value rather than by tag (a router rendering a route's `component`) carry only the function name. +- An `at` line when the build wrote source locations (the compiler's `sourceNames` `locations` kind — on by default in dev builds, an opt-in for `observe` ones): the innermost located node's `file:line:col`, relative to the project root — the binding, memo, effect, signal or store the finding is about (`loc` in its options literal), else the JSX site of the nearest enclosing component (`createComponent(Home, props, "Home", "src/App.tsx:12:5")`). In a browser it prints as the dev server's URL (`at http://localhost:5173/src/App.tsx:18:3`), which Chrome and Firefox link to the Sources panel; on the server, the bare path. The same string is `event.loc` on the structured event. Absent when nothing on the chain was located. - For a compiled JSX binding effect (attribute, class, style, property, spread, insert), the element it writes as a second console argument — hover highlights it on the page, click jumps to it in the Elements panel. The web runtime tags binding effects with their element in dev; the core prints whatever the subject knows. -- The first report of each code ends with a footer `solid-js` registers through an internal seam of the engine (not a public `DEV` method): the installed repair skill path (`node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md`) and the same file's stable GitHub URL anchored to the code's section — `DEV.guideUrl(code)`, the one place that URL is built, so a profiler track's Insights link (in dev) and the console footer agree. Perf, graph, and responsiveness codes add a second line pointing at `attribution.enable()` from `solid-js/attribution` and the `agent-loops` skill in `@solidjs/diagnostics`. +- The first report of each code ends with a footer `solid-js` registers through an internal seam of the engine (not a public `DEV` method): a line for a human — the code's page on the docs site, what it means and how to fix it (`DEV.guideUrl(code)`, the one place that URL is built, so a profiler track's Insights link (in dev) and the console footer agree) — and a line for an agent, the installed repair skill path (`node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md`) and the same file's stable GitHub URL, anchored to the code's section. Perf, graph, and responsiveness codes add a second line pointing at `attribution.enable()` from `solid-js/attribution` and the `agent-loops` skill in `@solidjs/diagnostics`. Attribution's own output (`[why-run]` chains) prints as collapsed console groups — one headline per re-run, the cause chain and dependency delta inside. @@ -764,7 +765,7 @@ const events = capture.stop(); ### `DEV.guideUrl(code)` -Dev builds only. The repair guide's section for a code — the `reactivity-diagnostics` skill's stable GitHub URL, anchored to the code (`…/SKILL.md#strict_read_untracked`). One place builds it: the console footer's "learn more" line and the profiler track's Insights link (`learnMoreUrl`, in dev) both read it, so a tool that renders findings elsewhere links to the same text. Dev-tier rather than observe because it is guidance for a developer, and the URL string on a retained object would be a cost every observe build paid. +Dev builds only. The code's page on the docs site — `https://v2.solidjs.com/reference/solid-js/diagnostics/`, the slug being the code lowercased with `-` for `_` (`…/diagnostics/strict-read-untracked`). One place builds it: the console footer's first line and the profiler track's Insights link (`learnMoreUrl`, in dev) both read it, so a tool that renders findings elsewhere links to the same page. Dev-tier rather than observe because it is guidance for a developer, and the URL string on a retained object would be a cost every observe build paid. ### `OBSERVE.ownerPath(subject)` diff --git a/packages/diagnostics/tests/capture.test.ts b/packages/diagnostics/tests/capture.test.ts index 33f175eb3d..03242b6c9d 100644 --- a/packages/diagnostics/tests/capture.test.ts +++ b/packages/diagnostics/tests/capture.test.ts @@ -4,7 +4,8 @@ import { createMemo, createRoot, createSignal, - flush + flush, + getOwner } from "@solidjs/signals"; import { artifactToJSONL, @@ -40,6 +41,40 @@ describe("captureArtifact — diagnostics channel", () => { expectNoDiagnostics(artifact, { allow: ["NO_OWNER_EFFECT"] }); }); + it("keeps a finding's source location on the artifact and in the assertion's evidence", async () => { + const { artifact } = await captureArtifact( + () => { + createRoot(() => { + createMemo( + () => { + OBSERVE!.diagnostics.emit( + { code: "INVARIANT_VIOLATION", kind: "error", severity: "error", message: "probe" }, + getOwner() + ); + return 1; + }, + { name: "label", loc: "src/components/TodoRow.tsx:18:3" } + )(); + }); + }, + { scenario: "located finding", attribution: false } + ); + const [event] = artifact.diagnostics; + expect(event.ownerPath).toEqual(["label"]); + expect(event.loc).toBe("src/components/TodoRow.tsx:18:3"); + // The failure message prints the offending events, location included. + let message = ""; + try { + expectNoDiagnostics(artifact); + } catch (error) { + message = (error as Error).message; + } + expect(message).toContain('"ownerPath":["label"]'); + expect(message).toContain('"loc":"src/components/TodoRow.tsx:18:3"'); + // And the JSONL form carries it verbatim. + expect(artifactToJSONL(artifact)).toContain('"loc":"src/components/TodoRow.tsx:18:3"'); + }); + it("captures nothing for a clean scenario", async () => { const { artifact } = await captureArtifact( () => { diff --git a/packages/signals/scripts/mangle-props.mjs b/packages/signals/scripts/mangle-props.mjs index 69c13235dc..8dead27bf2 100644 --- a/packages/signals/scripts/mangle-props.mjs +++ b/packages/signals/scripts/mangle-props.mjs @@ -40,9 +40,11 @@ for (const dir of process.argv.slice(2)) { keep_classnames: true, keep_fnames: true, module: false, - // Two cross-package owner fields, both reserved: `_name` — solid-js + // Three cross-package owner fields, all reserved: `_name` — solid-js // writes the component label onto signals' owners (`owner._name = - // ""`) and `ownerPath` reads it — and `_parent`, the owner-tree + // ""`) and `ownerPath` reads it — `_loc`, the source location + // solid-js writes beside it (`owner._loc = "src/App.tsx:3:5"`) and + // `emitDiagnostic` reads for `event.loc` — and `_parent`, the owner-tree // link, which solid-js walks on signals' owners (client hydration's // root lookup) and which the core walks on solid-js's server owners // (`ownerPath`, `OBSERVE.exclude`/`isExcluded`). Mangling either @@ -52,7 +54,7 @@ for (const dir of process.argv.slice(2)) { // in the observe tier. Every other `_` field is private to this // package; solid's cross-package-fields spec scans the downstream // artifacts for any new one. - properties: { regex: /^_/, reserved: ["_name", "_parent"] } + properties: { regex: /^_/, reserved: ["_name", "_loc", "_parent"] } }, // preserve_annotations: terser consumes /*@__PURE__*/ during parse and // only re-emits it when asked — without this the prod tree loses the diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 1d1f87a1f1..f6be5e45ed 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -897,12 +897,12 @@ export function computed( const loading = options !== null && typeof options === "object" && "loadingValue" in options; // Two literals, one per tier, selected at build time (the observe flag is // a literal after replacement; the untaken branch is dead code). The observe - // literal is the prod literal plus its `_name` slot — a slot in the - // boilerplate, because a post-construction `self._name = …` forces a + // literal is the prod literal plus its `_name` and `_loc` slots — slots in + // the boilerplate, because a post-construction `self._name = …` forces a // hidden-class transition and an out-of-object property store on EVERY node // (measured: the whole of the observe tier's creation overhead). Keep the // two in sync — the dist artifact test pins observe's key set to prod's - // plus `_name`. + // plus the slots. const self: Computed = __OBSERVE__ ? ({ id: inheritId(options, transparent, context), @@ -945,6 +945,7 @@ export function computed( // The slot is always present (hidden class); plumbing leaves it // unset, which `ownerPath` skips. _name: options?._plumbing ? undefined : (options?.name ?? "computed"), + _loc: options?.loc, // Observe: the verdict windows this pass entered (verdict.ts) — a // slot, so the stamp is never a write after construction. _devWindows: 0 @@ -1200,6 +1201,7 @@ export function createEffectNode( _type: type, _x: null, _name: options?.name ?? "effect", + _loc: options?.loc, _devWindows: 0 } as any) : ({ @@ -1312,7 +1314,7 @@ function setupComputedNode(self: Computed, options: NodeOptions | undef export function signal(v: T, options?: NodeOptions): Signal { // Prod and observe boilerplates — see computed(). The observe literal adds - // `_name` and `_owner` (the creating owner, stamped by registerGraph for + // `_name`, `_loc` and `_owner` (the creating owner, stamped by registerGraph for // createSignal nodes so ownerPath can locate signal subjects; null here, // and staying null on internal signals — one shape either way). const s = __OBSERVE__ @@ -1329,6 +1331,7 @@ export function signal(v: T, options?: NodeOptions): Signal { _notifiedAt: -1, _x: null, _name: options?.name ?? "signal", + _loc: options?.loc, _owner: null as Owner | null } : { @@ -1372,7 +1375,8 @@ export function isEqual(a: T, b: T): boolean { * object, no NodeExtension, no post-construction expandos. `ownedWrite` is * baked in: the setter carries the owned-scope guard; node-level writes are * the store's notification machinery. The observe literal adds the label - * slot (the store relabels it `store.` when the engine is installed). */ + * and location slots (the store relabels `store.` and stamps the + * store's `loc` when the engine is installed). */ export function slotSignal( v: T, equals: (a: T, b: T) => boolean, @@ -1397,6 +1401,7 @@ export function slotSignal( px: undefined, pxv: undefined, _name: "signal", + _loc: undefined as string | undefined, _owner: null as Owner | null } : { diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 5518461401..1209058f8b 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -151,6 +151,15 @@ export interface DiagnosticEvent { * is usually the finding itself). */ ownerPath?: string[]; + /** + * Source location of the innermost located node enclosing the subject + * (`file:line:col`, root-relative — the compiler's `sourceNames` + * `locations` kind): the subject's own `loc`, else the nearest owner's up + * the chain (a component root carries its JSX site). Absent when nothing + * on the chain was compiled with locations. A host event that sets it + * keeps it. + */ + loc?: string; data?: Record; } @@ -482,12 +491,14 @@ export interface Dev { /** Console face of an emitted event — see `reportDiagnostic`. */ report(entry: DiagnosticEvent): void; /** - * The stable URL of `code`'s section in the repair guide — the - * `reactivity-diagnostics` skill shipped with `solid-js`, one section per - * code. The one place the URL is built: the console footer prints it and - * the performance tracks' Insights link (`learnMoreUrl`) reads it, so both - * name the same section. Dev-tier: it is guidance for a developer, and a - * URL string on a retained object is a cost every observe build would pay. + * The URL of `code`'s page in the docs — what the finding means and how + * to fix it, one page per code + * (`https://v2.solidjs.com/reference/solid-js/diagnostics/`, slug = + * the code lowercased with `-` for `_`). The one place the URL is built: + * the console footer prints it and the performance tracks' Insights link + * (`learnMoreUrl`) reads it, so both name the same page. Dev-tier: it is + * guidance for a developer, and a URL string on a retained object is a + * cost every observe build would pay. */ guideUrl(code: DiagnosticCode): string; } @@ -697,10 +708,11 @@ export function isSuppressed(entry: DiagnosticEvent): boolean { } // The repair guide: the `reactivity-diagnostics` skill `solid-js` ships, -// one section per code, at its stable GitHub path. GitHub heading anchors -// are lowercased with underscores kept (`### SILENT_HOLD` → `#silent_hold`). -const GUIDE_URL = - "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md"; +// one page per code on the docs site. The slug is the code lowercased with +// `_` → `-` (`STRICT_READ_UNTRACKED` → `strict-read-untracked`). The host +// flips to docs.solidjs.com at stable (one search-and-replace, with the JSDoc +// links). +const GUIDE_URL = "https://v2.solidjs.com/reference/solid-js/diagnostics/"; export const DEV: Dev = __DEV__ ? { @@ -712,7 +724,7 @@ export const DEV: Dev = __DEV__ getObservers, report: reportDiagnostic, guideUrl(code) { - return `${GUIDE_URL}#${code.toLowerCase()}`; + return GUIDE_URL + code.toLowerCase().replace(/_/g, "-"); } } : (undefined as unknown as Dev); @@ -760,15 +772,35 @@ export function ownerPath(subject: DiagnosticSubject | null | undefined): string return path.length ? path.reverse() : undefined; } +/** + * The innermost source location on the subject's chain: the subject's own + * `_loc` (a signal or computation created with `{ loc }`), else the first + * `_loc` up the owners — a signal hops to its registering owner, then + * `_parent` upward (a component root carries its JSX site). The same walk + * `ownerPath` makes; `undefined` when nothing on the chain was located. + */ +function locationOf(subject: DiagnosticSubject | null | undefined): string | undefined { + if (!subject) return undefined; + const own = (subject as any)._loc; + if (typeof own === "string" && own.length) return own; + let owner: Owner | null = + "_parent" in subject ? subject._parent : (((subject as any)._owner as Owner | null) ?? null); + for (; owner !== null; owner = owner._parent) { + const loc = (owner as any)._loc; + if (typeof loc === "string" && loc.length) return loc; + } + return undefined; +} + /** * Records a diagnostic on the structured channel (listeners, captures) and * returns the entry. `subject` locates it: the current reactive `context` by * default (right for the synchronous rule checks — they fire inside the * scope that misbehaved); pass the node for scheduler-time findings whose * ambient context is the flush, or `null` for events that have no location - * by nature. An `ownerPath` already on the event wins over the subject walk - * (hosts whose owners are not signals' owners compute their own). Console - * output is a separate, dev-tier step — see `reportDiagnostic`. + * by nature. An `ownerPath` (or `loc`) already on the event wins over the + * subject walk (hosts whose owners are not signals' owners compute their + * own). Console output is a separate, dev-tier step — see `reportDiagnostic`. */ export function emitDiagnostic( event: Omit, @@ -788,6 +820,10 @@ export function emitDiagnostic( const path = ownerPath(subject); if (path) entry.ownerPath = path; } + if (entry.loc === undefined) { + const loc = locationOf(subject); + if (loc !== undefined) entry.loc = loc; + } const live = subject ?? undefined; if (live !== undefined) eventSubjects.set(entry, live); for (const listener of diagnosticListeners) listener(entry, live); @@ -827,12 +863,32 @@ function takeFooter(entry: DiagnosticEvent): string | undefined { */ const eventSubjects = new WeakMap(); +/** + * The `at` line of a console report: the event's `loc` as the compiler wrote + * it (`src/App.tsx:18:3`), or — in a browser, where a dev server serves the + * project root at `location.origin` — `http://localhost:5173/src/App.tsx:18:3`, + * which Chrome and Firefox link to the Sources panel. A path that leaves the + * root (`../`) or is absolute is not under the origin and prints as-is. + */ +function locationLine(loc: string): string { + if ( + typeof document !== "undefined" && + typeof location !== "undefined" && + !loc.startsWith("..") && + !loc.startsWith("/") + ) + return `${location.origin}/${loc}`; + return loc; +} + /** * The console face of a diagnostic — ONE entry per finding: the message, the * owner path (`in › › effect`) so a human can locate it, the - * once-per-code footer as trailing lines, and — when the subject is a - * binding effect the rendering runtime tagged — the element it writes, as a - * second console argument (hover highlights it, click jumps to Elements). + * source location (`at src/App.tsx:18:3` — see `locationLine`) when a node on + * the chain was compiled with one, the once-per-code footer as trailing + * lines, and — when the subject is a binding effect the rendering runtime + * tagged — the element it writes, as a second console argument (hover + * highlights it, click jumps to Elements). * Severity picks the console method. Call sites report the entry * `emitDiagnostic` returned so the structured and console channels never * disagree. Dev-tier: in an observe build this is a no-op, so wiring paths @@ -843,6 +899,7 @@ export function reportDiagnostic(entry: DiagnosticEvent): void { if (!__DEV__ || suppressedEvents.has(entry)) return; let text = entry.message; if (entry.ownerPath) text += `\n in ${entry.ownerPath.join(" › ")}`; + if (entry.loc) text += `\n at ${locationLine(entry.loc)}`; const footer = takeFooter(entry); if (footer) text += `\n${footer}`; const element = (eventSubjects.get(entry) as { _devElement?: object } | undefined)?._devElement; diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index 618d10ceb2..487354ce73 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -346,9 +346,10 @@ export function createOwner(options?: { id?: string; transparent?: boolean }) { const parent = context; const transparent = options?.transparent ?? false; // Prod and observe boilerplates (see core.ts computed()). The observe - // literal carries the `_name` slot the rendering layer fills with the - // component label (`owner._name = ""`) — a slot, so labelling a root - // is a plain store rather than a shape fork between labelled and plain roots. + // literal carries the `_name` and `_loc` slots the rendering layer fills + // with the component label and JSX site (`owner._name = ""`, + // `owner._loc = "src/App.tsx:3:5"`) — slots, so labelling a root is a + // plain store rather than a shape fork between labelled and plain roots. const owner = __OBSERVE__ ? ({ id: inheritId(options, transparent, parent), @@ -364,7 +365,8 @@ export function createOwner(options?: { id?: string; transparent?: boolean }) { _x: null, _parent: parent, dispose: disposeRootSelf, - _name: undefined as string | undefined + _name: undefined as string | undefined, + _loc: undefined as string | undefined } as Root) : ({ id: inheritId(options, transparent, parent), diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 6074adfb26..e55133c64f 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -22,6 +22,13 @@ export interface Link { export interface NodeOptions { id?: string; name?: string; + /** + * Source location (`file:line:col`, root-relative) written by the + * compiler's `sourceNames` `locations` kind; dev and observe builds keep it + * for console reports and diagnostic events, production ignores it. Not + * meant to be written by hand. + */ + loc?: string; transparent?: boolean; equals?: ((prev: T, next: T) => boolean) | false; ownedWrite?: boolean; @@ -169,6 +176,13 @@ export interface Owner { * (``). A slot in the observe/dev literals; absent from prod. */ _name?: string; + /** + * Observe-tier source location (`file:line:col`): the `loc` option, or + * the JSX site the rendering layer writes on a component root. A slot + * beside `_name`; absent from prod. Reserved from property mangling, like + * `_name`, because solid-js writes it and the core's diagnostics read it. + */ + _loc?: string; } export interface Computed extends RawSignal, Owner { diff --git a/packages/signals/src/store/optimistic.ts b/packages/signals/src/store/optimistic.ts index 6e23e75034..fcadbe4ca3 100644 --- a/packages/signals/src/store/optimistic.ts +++ b/packages/signals/src/store/optimistic.ts @@ -650,16 +650,17 @@ export function createOptimisticStore( : (row: any) => (isWrappable(row) ? row[keyOption] : undefined) }; const store = wrap(initialValue as any, null, null, fam) as Store; - if (__OBSERVE__) nameStore(store, options?.name); + if (__OBSERVE__) nameStore(store, options?.name, options?.loc); if (fam.shallow) { ((store as any)[$TARGET] as StoreTarget).s = true; markRawIngest(initialValue); } if (derived) { const fn = first as (store: T) => void | T | Promise | AsyncIterable; - let nodeOptions: { name?: string; loadingValue?: void } | undefined; + let nodeOptions: { name?: string; loc?: string; loadingValue?: void } | undefined; if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; - if (__OBSERVE__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; + if (__OBSERVE__ && (options?.name || options?.loc)) + nodeOptions = { ...nodeOptions, name: options.name, loc: options.loc }; const node = computed(() => { if (!fam.node) fam.node = getOwner() as Computed; runProjectionComputed(store, fn, keyOption); diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index d582f519b2..787f6c58a3 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -334,15 +334,15 @@ function createProjectionInternal( ((store as any)[$TARGET] as StoreTarget).s = true; markRawIngest(seed); } - let nodeOptions: { name?: string; loadingValue?: void } | undefined; + let nodeOptions: { name?: string; loc?: string; loadingValue?: void } | undefined; if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; if (__OBSERVE__ && attrHooks !== null) // As createStore: the owner in scope stamps the projection's property // nodes, so attribution places them under it (#4035). storeOwners!.set((store as any)[$TARGET] as StoreTarget, getOwner()); - if (__OBSERVE__ && options?.name) { - nodeOptions = { ...nodeOptions, name: options.name }; - nameStore(store, options.name); + if (__OBSERVE__ && (options?.name || options?.loc)) { + nodeOptions = { ...nodeOptions, name: options.name, loc: options.loc }; + nameStore(store, options.name, options.loc); } const node = computed(() => { if (!fam.node) fam.node = getOwner() as Computed; diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 0125226243..186137547a 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -407,6 +407,7 @@ export function getNode( // check (nodes created before enable() stay generically named). if (__OBSERVE__ && attrHooks !== null) { (created as any)._name = storeLabel(target) + "." + String(key); + (created as any)._loc = storeLoc(target); stampNodeOwner(created, target); } nodes[key] = node; @@ -539,13 +540,22 @@ function stampNodeOwner(created: Signal, target: StoreTarget): void { (created as any)._owner = storeRootOwner(target) ?? null; } const storeNames: WeakMap | null = __OBSERVE__ ? new WeakMap() : null; -export function nameStore(proxy: any, name: string | undefined): void { - if (__OBSERVE__ && attrHooks !== null && name) - storeNames!.set((proxy as any)[$TARGET] as StoreTarget, name); +// The store's `loc` option, carried onto every property node beside its +// label (the same gate: nodes created before the engine is installed carry +// neither). +const storeLocs: WeakMap | null = __OBSERVE__ ? new WeakMap() : null; +export function nameStore(proxy: any, name: string | undefined, loc?: string): void { + if (__OBSERVE__ && attrHooks !== null) { + if (name) storeNames!.set((proxy as any)[$TARGET] as StoreTarget, name); + if (loc) storeLocs!.set((proxy as any)[$TARGET] as StoreTarget, loc); + } } function storeLabel(target: StoreTarget): string { return storeNames!.get(storeRoot(target)) ?? "store"; } +function storeLoc(target: StoreTarget): string | undefined { + return storeLocs!.get(storeRoot(target)); +} // --------------------------------------------------------------------------- // clones (CoW privatization) @@ -2382,7 +2392,7 @@ export function createStore>( // disabled, buys nothing. if (attrHooks !== null) { storeOwners!.set((proxy as any)[$TARGET] as StoreTarget, owner); - nameStore(proxy, options?.name); + nameStore(proxy, options?.name, options?.loc); } } const setter: SetStoreFunction = fn => storeSetter(proxy, fn); diff --git a/packages/signals/src/store/types.ts b/packages/signals/src/store/types.ts index d4295f7f33..8b737f196e 100644 --- a/packages/signals/src/store/types.ts +++ b/packages/signals/src/store/types.ts @@ -38,6 +38,13 @@ export interface StoreOptions { * projection node carries the name itself. */ name?: string; + /** + * Source location (`file:line:col`, root-relative) written by the + * compiler's `sourceNames` `locations` kind; dev and observe builds keep it + * for console reports and diagnostic events, production ignores it. Not + * meant to be written by hand. + */ + loc?: string; /** Single-layer store: root keys reactive, values raw records replaced by reference */ shallow?: boolean; } diff --git a/packages/signals/tests/diagnostics.test.ts b/packages/signals/tests/diagnostics.test.ts index b8058f9bdc..926bc3b161 100644 --- a/packages/signals/tests/diagnostics.test.ts +++ b/packages/signals/tests/diagnostics.test.ts @@ -282,7 +282,7 @@ describe("diagnostics", () => { }); describe("diagnostics console footer", () => { - it("links every diagnostic code to an existing guide section", () => { + it("every diagnostic code has a section in the repair skill (the footer's agent line anchors to it)", () => { const source = readFileSync(new URL("../src/core/dev.ts", import.meta.url), "utf8"); const declaration = source.match(/export type DiagnosticCode =([\s\S]*?);/); expect(declaration).not.toBeNull(); @@ -295,21 +295,26 @@ describe("diagnostics console footer", () => { new URL("../../solid/skills/reactivity-diagnostics/SKILL.md", import.meta.url), "utf8" ); + // GitHub heading anchors: the heading lowercased, underscores kept — + // what solid-js's footer appends to the skill URL (`#silent_hold`). const anchors = Array.from(guide.matchAll(/^#{3,4} ([A-Z][A-Z0-9_]*)$/gm), match => match[1].toLowerCase() ); - const missing = codes.filter( - code => !anchors.includes(new URL(DEV!.guideUrl(code)).hash.slice(1)) - ); + const missing = codes.filter(code => !anchors.includes(code.toLowerCase())); expect(missing).toEqual([]); }); - it("DEV.guideUrl is the repair guide's section for a code", () => { - const url = DEV!.guideUrl("STRICT_READ_UNTRACKED"); - expect(url).toMatch( - /^https:\/\/github\.com\/solidjs\/solid\/blob\/next\/.*SKILL\.md#strict_read_untracked$/ + it("DEV.guideUrl is the code's page on the docs site", () => { + expect(DEV!.guideUrl("STRICT_READ_UNTRACKED")).toBe( + "https://v2.solidjs.com/reference/solid-js/diagnostics/strict-read-untracked" + ); + // The slug is the code lowercased with `-` for every `_`. + expect(DEV!.guideUrl("SILENT_HOLD")).toBe( + "https://v2.solidjs.com/reference/solid-js/diagnostics/silent-hold" + ); + expect(DEV!.guideUrl("NO_OWNER_EFFECT")).toBe( + "https://v2.solidjs.com/reference/solid-js/diagnostics/no-owner-effect" ); - expect(DEV!.guideUrl("SILENT_HOLD")).toBe(url.replace(/#.*$/, "#silent_hold")); }); afterEach(() => { @@ -435,6 +440,166 @@ describe("diagnostics console footer", () => { }); }); +describe("diagnostics source locations", () => { + // Stand-in for solid-js's observedComponent, which labels each component + // root with its tag and JSX site (`createComponent(Home, props, "Home", + // "src/App.tsx:12:5")`). + const labelComponent = (name: string, loc?: string) => { + const owner = getOwner() as any; + owner._name = name; + owner._loc = loc; + }; + const emit = (subject?: any) => + emitDiagnostic( + { code: "INVARIANT_VIOLATION", kind: "error", severity: "error", message: "probe" }, + subject + ); + + it("a finding inside a computation created with { loc } carries it as event.loc", () => { + let event: any; + createRoot(() => { + createMemo( + () => { + event = emit(); + return 1; + }, + { name: "label", loc: "src/components/TodoRow.tsx:18:3" } + )(); + }); + expect(event.loc).toBe("src/components/TodoRow.tsx:18:3"); + expect(event.ownerPath).toEqual(["label"]); + }); + + it("the innermost located node wins: an effect's own loc over the component's", () => { + let event: any; + createRoot(() => { + labelComponent("", "src/App.tsx:3:5"); + createEffect( + () => { + event = emit(); + return 1; + }, + () => {}, + { loc: "src/App.tsx:14:7" } + ); + }); + flush(); + expect(event.loc).toBe("src/App.tsx:14:7"); + }); + + it("the component root's loc wins when the scope has none", () => { + let event: any; + createRoot(() => { + labelComponent("", "src/App.tsx:3:5"); + createRoot(() => { + // An unlocated, unnamed intermediate root (a list row scope). + createMemo(() => { + event = emit(); + return 1; + })(); + }); + }); + expect(event.loc).toBe("src/App.tsx:3:5"); + expect(event.ownerPath).toEqual(["", "computed"]); + }); + + it("a signal subject resolves to its own loc, else its registering owner's", () => { + let own: any; + let inherited: any; + createRoot(() => { + labelComponent("", "src/Counter.tsx:2:1"); + createSignal(0, { name: "count", loc: "src/Counter.tsx:3:22" }); + createSignal(0); + const [located, plain] = DEV!.getSignals(getOwner()!); + own = emit(located); + inherited = emit(plain); + }); + expect(own.loc).toBe("src/Counter.tsx:3:22"); + expect(inherited.loc).toBe("src/Counter.tsx:2:1"); + }); + + it("is absent when nothing on the chain was located, and a host event's loc is kept", () => { + let unlocated: any; + let host: any; + createRoot(() => { + labelComponent(""); + unlocated = emit(); + host = emitDiagnostic({ + code: "HYDRATION_MISMATCH", + kind: "hydration", + severity: "error", + message: "host", + loc: "src/Page.tsx:9:9" + }); + }); + expect(unlocated.loc).toBeUndefined(); + expect("loc" in unlocated).toBe(false); + expect(host.loc).toBe("src/Page.tsx:9:9"); + }); + + it("reportDiagnostic prints an `at` line after the `in` line and before the footer", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + setConsoleFooter(event => `footer:${event.code}`); + createRoot(() => { + labelComponent("", "src/App.tsx:3:5"); + createMemo( + () => { + reportDiagnostic( + emitDiagnostic({ + code: "STRICT_READ_UNTRACKED", + kind: "strict-read", + severity: "warn", + message: "[STRICT_READ_UNTRACKED] probe" + }) + ); + return 1; + }, + { name: "label", loc: "src/components/TodoRow.tsx:18:3" } + )(); + }); + setConsoleFooter(undefined); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toBe( + "[STRICT_READ_UNTRACKED] probe\n" + + " in › label\n" + + " at src/components/TodoRow.tsx:18:3\n" + + "footer:STRICT_READ_UNTRACKED" + ); + }); + + it("in a browser the `at` line is the dev-server URL of a root-relative path", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("document", {}); + vi.stubGlobal("location", { origin: "http://localhost:5173" }); + try { + const report = (loc: string) => + reportDiagnostic( + emitDiagnostic( + { + code: "STRICT_READ_UNTRACKED", + kind: "strict-read", + severity: "warn", + message: "probe", + loc + }, + null + ) + ); + report("src/App.tsx:18:3"); + // Outside the root, or absolute: not under the origin — printed as-is. + report("../shared/util.ts:4:1"); + report("/abs/path/App.tsx:1:1"); + } finally { + vi.unstubAllGlobals(); + } + expect(warn.mock.calls.map(args => String(args[0]))).toEqual([ + "probe\n at http://localhost:5173/src/App.tsx:18:3", + "probe\n at ../shared/util.ts:4:1", + "probe\n at /abs/path/App.tsx:1:1" + ]); + }); +}); + describe("diagnostics owner path", () => { // Stand-in for solid-js's observedComponent, which labels each component root. const nameOwner = (name: string) => ((getOwner() as any)._name = name); diff --git a/packages/signals/tests/dist-artifacts.test.ts b/packages/signals/tests/dist-artifacts.test.ts index 1e71fbfeae..e6267f7a2c 100644 --- a/packages/signals/tests/dist-artifacts.test.ts +++ b/packages/signals/tests/dist-artifacts.test.ts @@ -374,15 +374,15 @@ describe("@solidjs/signals awaited refresh per tier", () => { describe("@solidjs/signals node literals per tier", () => { // Each node factory has two object literals — prod, and observe = prod plus - // its diagnostic slots (`_name`; `_owner` on signals) — selected at build + // its diagnostic slots (`_name`, `_loc`; `_owner` on signals) — selected at build // time. The slots MUST be in the literal: written after construction they // force a hidden-class transition and an out-of-object property on every // node, which was the whole of the observe tier's measured creation // overhead. Two hand-kept literals can drift, so this pins the invariant // from the built trees: the observe literal's keys are exactly the prod // literal's keys plus the slots, and the prod literal has no slot at all. - // Field names are mangled per tree (`_name` is reserved), so the - // comparison is by count and by the reserved name. + // Field names are mangled per tree (`_name` and `_loc` are reserved), so + // the comparison is by count and by the reserved names. async function literalKeys(tier: "prod" | "observe") { const core = (await import(`../dist/${tier}/core/core.js`)) as any; const owner = (await import(`../dist/${tier}/core/owner.js`)) as any; @@ -411,17 +411,19 @@ describe("@solidjs/signals node literals per tier", () => { const prod = await literalKeys("prod"); const observe = await literalKeys("observe"); const slots: Record = { - owner: ["_name"], - signal: ["_name", "_owner"], + owner: ["_name", "_loc"], + signal: ["_name", "_loc", "_owner"], // L2 (§31 S1): the slot literal carries `_owner` like `signal` — the // attribution stamp fills a slot instead of adding a field after. - slotSignal: ["_name", "_owner"], - computed: ["_name", "_devWindows"], - effect: ["_name", "_devWindows"] + slotSignal: ["_name", "_loc", "_owner"], + computed: ["_name", "_loc", "_devWindows"], + effect: ["_name", "_loc", "_devWindows"] }; for (const kind of Object.keys(slots)) { expect(prod[kind], `${kind} prod literal carries a slot`).not.toContain("_name"); + expect(prod[kind], `${kind} prod literal carries a slot`).not.toContain("_loc"); expect(observe[kind], `${kind} observe literal lacks its slot`).toContain("_name"); + expect(observe[kind], `${kind} observe literal lacks its slot`).toContain("_loc"); expect(observe[kind].length, `${kind} literals drifted`).toBe( prod[kind].length + slots[kind].length ); @@ -438,10 +440,12 @@ describe("@solidjs/signals node literals per tier", () => { // Default labels and a supplied name both come out of the same literal: // the key set is identical either way. const plain = core.computed(() => 0); - const named = core.computed(() => 0, { name: "n" }); + const named = core.computed(() => 0, { name: "n", loc: "src/a.ts:1:1" }); expect(Object.keys(named)).toEqual(Object.keys(plain)); expect(plain._name).toBe("computed"); expect(named._name).toBe("n"); + expect(plain._loc).toBeUndefined(); + expect(named._loc).toBe("src/a.ts:1:1"); // The default effect label comes from the node kind, not from an options // spread in the wrapper (which allocated per effect in observe). let effectName: string | undefined; diff --git a/packages/solid/diagnostics.json b/packages/solid/diagnostics.json index e3c1e0353e..a8bc84353f 100644 --- a/packages/solid/diagnostics.json +++ b/packages/solid/diagnostics.json @@ -6,7 +6,7 @@ "category": "responsiveness", "trigger": "One async source abandoned 3+ flights in 1s, each superseded before landing — the request-per-keystroke signature (attribution enabled)", "repair": "One async source started flight after flight and threw most of the answers away: `data.abandoned` flights in `data.windowMs` were each superseded by the next before landing.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#abandoned_flights" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/abandoned-flights" }, { "code": "ACTION_CALLED_IN_OWNED_SCOPE", @@ -15,7 +15,7 @@ "category": "write", "trigger": "`action()` invoked from a component body or computation", "repair": "An action was invoked during a component body or computation.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#action_called_in_owned_scope" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/action-called-in-owned-scope" }, { "code": "ASYNC_OUTSIDE_LOADING_BOUNDARY", @@ -24,7 +24,7 @@ "category": "async", "trigger": "Async computation outside Loading boundary (non-halting; root mount is deferred)", "repair": "On the client, an async value was read outside a Loading boundary during mounting.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#async_outside_loading_boundary" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/async-outside-loading-boundary" }, { "code": "ASYNC_STORE_SETTER", @@ -33,7 +33,7 @@ "category": "write", "trigger": "Store setter callback returned a Promise (setters are synchronous transactions)", "repair": "A store setter callback returned a Promise — `setStore(async d => …)`, or a sync arrow whose helper is async.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#async_store_setter" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/async-store-setter" }, { "code": "ASYNC_WATERFALL", @@ -42,7 +42,7 @@ "category": "perf", "trigger": "2+/3+ sequential async flights, origin-proven (attribution enabled)", "repair": "Async flights ran in sequence when they might have run in parallel: each named flight provably could not start until the previous one resolved, and each took real time (the per-link durations are in the message/data).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#async_waterfall" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/async-waterfall" }, { "code": "BINDING_SLOT_POSITION", @@ -51,7 +51,7 @@ "category": "ssr/render", "trigger": "Binding-slot value where it cannot bind; `data.reason` names the position (spread is an error and throws), client reasons `orphan`/`fill-shape`", "repair": "A binding slot's property (`const row = props.row(args); row.done`) landed where the server template cannot bind it.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#binding_slot_position" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/binding-slot-position" }, { "code": "CLEANUP_IN_FORBIDDEN_SCOPE", @@ -60,7 +60,7 @@ "category": "lifecycle", "trigger": "`onCleanup` inside trackedEffect/onSettled", "repair": "`onCleanup` inside `createTrackedEffect` or `onSettled` is not supported — return a cleanup function from the callback instead.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#cleanup_in_forbidden_scope" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/cleanup-in-forbidden-scope" }, { "code": "DYNAMIC_ASYNC_COMPONENT", @@ -69,7 +69,7 @@ "category": "ssr", "trigger": "An async `dynamic()` source landed on a client component function, which cannot serialize for adoption; the memo rejects (observe + dev; rejects in every tier)", "repair": "An async `dynamic()` source resolved to a client component function.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#dynamic_async_component" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/dynamic-async-component" }, { "code": "EFFECT_RELAY_TEAR", @@ -78,7 +78,7 @@ "category": "perf", "trigger": "Reader ran twice for one root change because an effect relayed it; `warn` when derivable or repeated (attribution enabled)", "repair": "Derived state kept in sync by an effect: `createEffect(() => f(a()), v => setS(v))`.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#effect_relay_tear" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/effect-relay-tear" }, { "code": "EFFECT_WRITES_OWN_SOURCE", @@ -87,7 +87,7 @@ "category": "perf", "trigger": "Effect's write provably feeds back into its own inputs; `info` for multi-effect rings (attribution enabled)", "repair": "An effect re-ran because of a write it made itself: its callback wrote a value that its own inputs depend on (directly, or through the memos the message names), so the flush settled in two passes and the screen rendered the pre-write value in between.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#effect_writes_own_source" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/effect-writes-own-source" }, { "code": "FALLBACK_FLASH", @@ -96,7 +96,7 @@ "category": "responsiveness", "trigger": "A `Loading` fallback showed for under 150ms — feedback for a wait too short to need it (attribution enabled)", "repair": "A `Loading` fallback appeared and vanished inside 150ms (`data.shownMs`): the data was nearly there, and the spinner read as a flicker.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#fallback_flash" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/fallback-flash" }, { "code": "FLUSH_IN_EFFECT_CALLBACK", @@ -105,7 +105,7 @@ "category": "lifecycle", "trigger": "`flush()` from an effect callback (no-op; the drain is already running)", "repair": "`flush()` was called inside an effect callback, where it is a no-op (the flush running effects is already in progress).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#flush_in_effect_callback" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/flush-in-effect-callback" }, { "code": "FRAME_MARKER_CORRUPTED", @@ -114,7 +114,7 @@ "category": "ssr", "trigger": "Frame slot range missing its end marker on the client — nesting or an HTML rewriter (observe + dev)", "repair": "(Client.) A frame's slot start marker has no end marker among its siblings: invalid HTML nesting split the range when the browser parsed it (a block element inside `

`, a `` outside a table), or a CDN/minifier stripped the comment.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#frame_marker_corrupted" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/frame-marker-corrupted" }, { "code": "GRAPH_GROWTH", @@ -123,7 +123,7 @@ "category": "perf", "trigger": "The live graph and what outlived each navigation climbed at a route's settle on 3 visits to 1.25× — something each visit leaves behind (attribution enabled)", "repair": "The live graph, counted when each navigation settles, got bigger on every one of the last `data.history.length` visits to `data.route`, and so did the part of it that outlived each of those navigations (`data.survived`, beside `data.history`: what was alive as this or an earlier navigation began and still is) — something each visit creates is never disposed.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#graph_growth" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/graph-growth" }, { "code": "HEAD_TAG_INVALID", @@ -132,7 +132,7 @@ "category": "head", "trigger": "`useHead` registration the render could not honor; `data.reason` names the rule (dev)", "repair": "A `useHead` registration the render could not honor; `data.reason` names the rule.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#head_tag_invalid" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/head-tag-invalid" }, { "code": "HOT_SCOPE_FANOUT", @@ -141,7 +141,7 @@ "category": "perf", "trigger": "5+/50+/500+ scopes hot from one root cause (attribution enabled)", "repair": "Many scopes went hot from ONE root cause (named in the message) — the aggregate form of HOT_SCOPE_RERUNS, emitted so a single culprit doesn't produce one warning per victim.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#hot_scope_fanout" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/hot-scope-fanout" }, { "code": "HOT_SCOPE_RERUNS", @@ -150,7 +150,7 @@ "category": "perf", "trigger": "120+ re-runs of one scope in 1s (attribution enabled)", "repair": "A scope re-ran far more often than any UI cadence justifies — a hot signal (often per-frame or per-event) is leaking into it.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#hot_scope_reruns" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/hot-scope-reruns" }, { "code": "HOT_SCOPE_TIME", @@ -159,7 +159,7 @@ "category": "perf", "trigger": "8ms+ self-time in one scope in 1s (attribution enabled)", "repair": "A scope's summed compute time blew its per-window budget — a few-but-expensive scope that run counts miss.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#hot_scope_time" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/hot-scope-time" }, { "code": "HUGE_FAN_IN", @@ -168,7 +168,7 @@ "category": "graph", "trigger": "One recompute tracked 2000 sources (always on)", "repair": "The always-on backstop: one computation tracked 2000 or more sources in a single pass, so it re-runs when any of them change.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#huge_fan_in" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/huge-fan-in" }, { "code": "HUGE_FAN_OUT", @@ -177,7 +177,7 @@ "category": "graph", "trigger": "One change reached 2000 live subscribers (always on), or 250 — `fanOut` — on a stamped root write (attribution enabled)", "repair": "One value has very many subscribers, so a single change re-runs all of them (`data.count`).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#huge_fan_out" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/huge-fan-out" }, { "code": "IMMUTABLE_UPDATE_IN_STORE", @@ -186,7 +186,7 @@ "category": "perf", "trigger": "Store container replaced by a mostly-identical copy (attribution enabled)", "repair": "A store setter replaced a container with a fresh object/array whose leaves are mostly the same values: `draft.user = { ...draft.user, name }`, `draft.items = [...draft.items, x]`, `draft.items = draft.items.filter(…)`.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#immutable_update_in_store" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/immutable-update-in-store" }, { "code": "INVALID_AFFECTS_TARGET", @@ -195,7 +195,7 @@ "category": "write", "trigger": "`affects()` given a key path, or a key on an accessor", "repair": "`affects()` expects a Solid source accessor or store node, with at most one optional key (keys only valid on store targets).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#invalid_affects_target" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/invalid-affects-target" }, { "code": "INVALID_REFRESH_TARGET", @@ -204,7 +204,7 @@ "category": "write", "trigger": "`refresh()` target is not a source accessor or refreshable store", "repair": "`refresh()` expects a Solid source accessor or refreshable store — not a wrapper function or a derived property read.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#invalid_refresh_target" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/invalid-refresh-target" }, { "code": "INVARIANT_VIOLATION", @@ -213,7 +213,7 @@ "category": "error", "trigger": "Internal consistency check failed (throws under `__TEST__`, reported in dev)", "repair": "The reactive system contradicted itself — an internal bug, not user error.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#invariant_violation" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/invariant-violation" }, { "code": "LATE_HEADER_WRITE", @@ -222,7 +222,7 @@ "category": "head", "trigger": "Response header written after the head was sent; dropped (observe + dev; dev throws)", "repair": "A response header was written after the head was sent; the write was dropped (dev throws instead).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#late_header_write" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/late-header-write" }, { "code": "LAZY_ASSET_UNMAPPED", @@ -231,7 +231,7 @@ "category": "ssr", "trigger": "`lazy()` component's client assets could not be resolved for the page (dev)", "repair": "A `lazy()` component's client chunk could not be resolved for the page (`data.reason`: the resolver threw, or the module has no `$$moduleUrl`), so it is not preloaded and loads late during hydration.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#lazy_asset_unmapped" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/lazy-asset-unmapped" }, { "code": "LOADING_ON_OUTSIDE_HOLD", @@ -240,7 +240,7 @@ "category": "async", "trigger": "`` changed but its data is also read outside the boundary and holds the frame: the fallback can never be seen", "repair": "`on` re-armed a Loading boundary, but a source it is waiting on is also read outside the boundary and holds the frame.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#loading_on_outside_hold" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/loading-on-outside-hold" }, { "code": "LONG_HOLD", @@ -249,7 +249,7 @@ "category": "responsiveness", "trigger": "Acknowledged hold whose tail (last input → commit) ran 500ms+/1000ms+ (attribution enabled)", "repair": "The hold WAS acknowledged — an `isPending()` spinner, a `latest()` header, an optimistic value — and still ran on: the time from the user's last input to the commit (`data.tailMs`; `holdMs` is the whole wait when input kept arriving) reached `longHolds.infoMs` (default 500ms), `warn` from `longHolds.warnMs` (default 1000ms, where the user loses the thread).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#long_hold" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/long-hold" }, { "code": "LOWERCASE_EVENT_ATTRIBUTE", @@ -258,7 +258,7 @@ "category": "render", "trigger": "A function was set on a lowercase `on*` (or `on:`) attribute, which is not an event handler in 2.0; use `onXxx` (dev; client)", "repair": "A function was set on an attribute whose name starts with `on` but is not an event handler (`data.name`): a lowercase `onclick`, or a leftover 1.x `on:click`.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#lowercase_event_attribute" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/lowercase-event-attribute" }, { "code": "MISSING_EFFECT_FN", @@ -267,7 +267,7 @@ "category": "lifecycle", "trigger": "`createEffect` called without the effect function", "repair": "`createEffect(compute)` with a single argument is not supported.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#missing_effect_fn" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/missing-effect-fn" }, { "code": "NO_OWNER_BOUNDARY", @@ -276,7 +276,7 @@ "category": "lifecycle", "trigger": "Boundary created without owner", "repair": "A boundary was created outside any reactive context.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#no_owner_boundary" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/no-owner-boundary" }, { "code": "NO_OWNER_CLEANUP", @@ -285,7 +285,7 @@ "category": "lifecycle", "trigger": "`onCleanup` called without owner", "repair": "`onCleanup` was called outside a reactive context; the callback will never run.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#no_owner_cleanup" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/no-owner-cleanup" }, { "code": "NO_OWNER_EFFECT", @@ -294,7 +294,7 @@ "category": "lifecycle", "trigger": "Effect created without reactive owner", "repair": "An effect was created outside any reactive context (no root, no component).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#no_owner_effect" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/no-owner-effect" }, { "code": "OPTIMISTIC_REVERTED", @@ -303,7 +303,7 @@ "category": "responsiveness", "trigger": "An optimistic value the screen showed was replaced by a different one — reverted at settle, or superseded by the truth (attribution enabled)", "repair": "An optimistic value the screen showed was replaced by a different one: the person saw the guess, then the correction.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#optimistic_reverted" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/optimistic-reverted" }, { "code": "PENDING_ASYNC_FORBIDDEN_SCOPE", @@ -312,7 +312,7 @@ "category": "async", "trigger": "Pending async read in trackedEffect/onSettled", "repair": "A pending async value was read inside `createTrackedEffect` or `onSettled`, which cannot suspend — it throws.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#pending_async_forbidden_scope" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/pending-async-forbidden-scope" }, { "code": "PENDING_ASYNC_UNTRACKED_READ", @@ -321,7 +321,7 @@ "category": "async", "trigger": "Reading pending async outside tracking scope", "repair": "Same shape as above but the value was a pending async computation, so there is nothing to read yet at all.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#pending_async_untracked_read" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/pending-async-untracked-read" }, { "code": "PRELOAD_DESCRIPTOR_INVALID", @@ -330,7 +330,7 @@ "category": "head", "trigger": "`registerAsset(\"preload\")` descriptor broke a field rule; link dropped or field ignored (dev)", "repair": "A `registerAsset(\"preload\")` descriptor broke a field rule (`data.field` names it — a missing `as`, a non-string `href`, a malformed `imagesrcset`) and the link was dropped or the field ignored.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#preload_descriptor_invalid" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/preload-descriptor-invalid" }, { "code": "PRIMITIVE_IN_EFFECT_CALLBACK", @@ -339,7 +339,7 @@ "category": "lifecycle", "trigger": "Memo, effect, or root created inside an effect callback or its cleanup, where there is no ambient owner (throws)", "repair": "A memo, effect, or root was created without an owner inside an effect callback or its cleanup.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#primitive_in_effect_callback" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/primitive-in-effect-callback" }, { "code": "PRIMITIVE_IN_FORBIDDEN_SCOPE", @@ -348,7 +348,7 @@ "category": "lifecycle", "trigger": "Reactive primitive created inside trackedEffect/onSettled", "repair": "Reactive primitives cannot be created inside `createTrackedEffect` or owner-backed `onSettled`.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#primitive_in_forbidden_scope" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/primitive-in-forbidden-scope" }, { "code": "REACTIVE_WRITE_IN_OWNED_SCOPE", @@ -357,7 +357,7 @@ "category": "write", "trigger": "Reactive write/invalidation inside component/computation", "repair": "A signal/store write (or `refresh()`) executed during an owned scope — a component body or a computation.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#reactive_write_in_owned_scope" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/reactive-write-in-owned-scope" }, { "code": "REACTIVITY_HALTED", @@ -366,7 +366,7 @@ "category": "error", "trigger": "Uncaught error escaped every boundary; scheduling stopped (reported, cause to `reportError`)", "repair": "An earlier uncaught error halted the reactive system; subsequent updates are ignored.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#reactivity_halted" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/reactivity-halted" }, { "code": "REVEAL_IN_RENDER_TO_STRING", @@ -375,7 +375,7 @@ "category": "ssr", "trigger": "Nested `` with collapsed/together under `renderToString` (dev)", "repair": "Nested `` with `collapsed`/`together` needs a stream to coordinate on; `renderToString` has none.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#reveal_in_render_to_string" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/reveal-in-render-to-string" }, { "code": "RUN_WITH_DISPOSED_OWNER", @@ -384,7 +384,7 @@ "category": "owner", "trigger": "`runWithOwner` with disposed owner", "repair": "`runWithOwner` received an owner that was already disposed; anything created inside leaks.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#run_with_disposed_owner" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/run-with-disposed-owner" }, { "code": "SERVER_ERROR_SANITIZED", @@ -393,7 +393,7 @@ "category": "ssr", "trigger": "Error replaced with the generic Error on the wire — `data.source` `\"server-function\"` (`error`) or `\"ssr\"` (`info`); `data.error` is the original (observe + dev)", "repair": "The production wire replaced an error with the generic `Error`; `data.error` is the original, `data.wire` what the client got, and `data.source` says which road:", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#server_error_sanitized" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/server-error-sanitized" }, { "code": "SERVER_WRITE", @@ -402,7 +402,7 @@ "category": "write", "trigger": "Signal/store/optimistic setter ran during a server render; inert, will throw (dev; once per category)", "repair": "A signal, store or optimistic setter ran during a server render.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#server_write" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/server-write" }, { "code": "SETTLED_CLEANUP_UNOWNED", @@ -411,7 +411,7 @@ "category": "lifecycle", "trigger": "`onSettled` returned a cleanup in an unowned (out-of-band) scope", "repair": "`onSettled` returned a cleanup while running in an unowned scope, so the cleanup cannot be honored.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#settled_cleanup_unowned" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/settled-cleanup-unowned" }, { "code": "SETTLE_WALK_UNINITIALIZED_SOURCE", @@ -420,7 +420,7 @@ "category": "lifecycle", "trigger": "Internal: settle walk reached a source that never produced a value (reported)", "repair": "The runtime tried to settle a source before it produced a value or an error.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#settle_walk_uninitialized_source" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/settle-walk-uninitialized-source" }, { "code": "SILENT_HOLD", @@ -429,7 +429,7 @@ "category": "responsiveness", "trigger": "Write held 100ms+/200ms+ by pending async with no on-screen acknowledgement (attribution enabled)", "repair": "A write landed on async work, so the runtime held it (and everything derived from it) until the data came back — that hold is correct; it is what keeps the screen from tearing.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#silent_hold" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/silent-hold" }, { "code": "SSR_BOUNDARY_WATERFALL", @@ -438,7 +438,7 @@ "category": "ssr", "trigger": "A `` boundary needed 3+/4+ render passes — 2+/3+ sequential async waits (dev)", "repair": "A `` boundary rendered in `data.passes` passes — discovery, then one per wait — and each pass past the first is a read that could only start once the previous pass's async answered: `passes - 1` sequential waits, `data.sequentialMs` end to end.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_boundary_waterfall" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-boundary-waterfall" }, { "code": "SSR_CLIENT_CONTENT_MASKED", @@ -447,7 +447,7 @@ "category": "ssr", "trigger": "Client-only content in a `` surfaced only after a server wait; the server's work was discarded (dev)", "repair": "A `` boundary's content turned out to be client-only (a `ssrSource: \"client\"` read) but only after `data.passes - 1` real server waits: an async read on an earlier pass masked it.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_client_content_masked" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-client-content-masked" }, { "code": "SSR_LIVE_HOLE_LATCHED", @@ -456,7 +456,7 @@ "category": "ssr", "trigger": "A live hole that shipped its markers was closed by the impurity gate on re-run and stopped updating (dev)", "repair": "A live markup hole (or attribute) in a server component shipped its markers and then stopped updating: re-running it created a reactive scope still waiting on async work (`data.cause: \"owners\"`) or emitted slot records (`\"records\"`), so the runtime closed it and the client keeps the markup it last received.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_live_hole_latched" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-live-hole-latched" }, { "code": "SSR_RENDER_ERROR_CONTAINED", @@ -465,7 +465,7 @@ "category": "ssr", "trigger": "Server render error routed by a boundary: `data.handling` fallback / client / failed (observe + dev)", "repair": "A component threw during a server render and a boundary routed the error; `data.handling` says how.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_render_error_contained" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-render-error-contained" }, { "code": "SSR_STREAM_ABANDONED", @@ -474,7 +474,7 @@ "category": "ssr", "trigger": "Response stream cancelled or sink failed with fragments pending (observe + dev)", "repair": "The client went away (`data.reason: \"consumer\"`), the sink failed (`\"sink\"`), or the request's `signal` aborted (`\"signal\"` — `renderToStream`'s `signal` option, how a frame-stream response learns its reader is gone) while `data.pendingFragments` were still rendering; the render was torn down.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_stream_abandoned" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-stream-abandoned" }, { "code": "SSR_SUBTREE_ABANDONED", @@ -483,7 +483,7 @@ "category": "ssr", "trigger": "A failed fragment's pending descendants were discarded (observe + dev)", "repair": "A fragment failed with descendants still pending, and their work (fetches, serialized values — the counts are in `data`) was thrown away; the client rebuilds the subtree.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_subtree_abandoned" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-subtree-abandoned" }, { "code": "SSR_UNDECLARED_LIVE_SOURCE", @@ -492,7 +492,7 @@ "category": "ssr", "trigger": "A document render is still pumping an async iterable in server-component scope 5s in — declare the function `live(...)` or bound the source (dev)", "repair": "A server component rendered into a document read an async iterable that was still producing `data.afterMs` (5s) later: an undeclared unbounded source pumps into the document and holds it open for as long as it produces.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#ssr_undeclared_live_source" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/ssr-undeclared-live-source" }, { "code": "STACKED_HOLDS", @@ -501,7 +501,7 @@ "category": "responsiveness", "trigger": "3+ interactions were waiting in one hold when it committed — repeats piled behind the same source (attribution enabled)", "repair": "`data.interactions` interactions were waiting in the same hold when it committed: the person clicked or typed again while the first answer was in the air, and the runtime folded every repeat into one wait on the same source (`data.blockers`).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#stacked_holds" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/stacked-holds" }, { "code": "STRICT_READ_UNTRACKED", @@ -510,7 +510,7 @@ "category": "strict-read", "trigger": "Untracked reactive read in component/effect body", "repair": "A reactive value was read outside any tracking scope (e.g. destructured props or a signal called in the component body).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#strict_read_untracked" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/strict-read-untracked" }, { "code": "SYNC_NODE_RECEIVED_ASYNC", @@ -519,7 +519,7 @@ "category": "lifecycle", "trigger": "`sync: true` computation returned a Promise / AsyncIterable", "repair": "A computed/effect created with `sync: true` returned a Promise or AsyncIterable; the value would be stored as-is, never awaited, in production.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#sync_node_received_async" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/sync-node-received-async" }, { "code": "UNRECOGNIZED_INSERT_VALUE", @@ -528,7 +528,7 @@ "category": "render", "trigger": "Value at an insert position the renderer cannot render; skipped (dev; server and client)", "repair": "A value the renderer cannot render (a plain object, a symbol — `data.type`) sat at an insert position and was skipped, on either platform.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#unrecognized_insert_value" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/unrecognized-insert-value" }, { "code": "UNSCOPED_HOLE_ALLOCATED_IDS", @@ -537,7 +537,7 @@ "category": "render", "trigger": "Unscoped hole was handed a function whose content took ids at a position the other side does not share; keys permute (dev)", "repair": "A JSX hole was handed a **function** (`data.name`) instead of a value — `const renderHead = () => props.header;

{renderHead}
` — and calling it built hydratable content.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#unscoped_hole_allocated_ids" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/unscoped-hole-allocated-ids" }, { "code": "UNSTABLE_LIST_IDENTITY", @@ -546,7 +546,7 @@ "category": "perf", "trigger": "`mapArray`/`For` recreated rows for equivalent items (attribution enabled)", "repair": "A `mapArray`/`` update disposed and recreated most rows while the entering items were field-for-field equivalent to the ones they replaced: a re-fetch (or a spread-copy, see above) handed back fresh objects for the same records, and identity keying treated each as a new row — DOM, state, focus, and scroll position thrown away and rebuilt.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#unstable_list_identity" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/unstable-list-identity" }, { "code": "UNSTABLE_MEMO_OUTPUT", @@ -555,7 +555,7 @@ "category": "perf", "trigger": "Memo returned a new-but-equivalent container 4+ runs running (attribution enabled)", "repair": "A memo keeps producing referentially-new but shallowly-equivalent objects/arrays, so its equality gate never closes and every subscriber re-runs on every upstream change.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#unstable_memo_output" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/unstable-memo-output" }, { "code": "UNTRACKED_ASYNC_HANDLER", @@ -564,7 +564,7 @@ "category": "responsiveness", "trigger": "Handler awaited 100ms+/200ms+ past its frame with no write before the `await` and no `action()`: a dead click no hold could judge (attribution enabled)", "repair": "The dead click that is not a hold.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#untracked_async_handler" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/untracked-async-handler" }, { "code": "UNTRACKED_READ_AFTER_AWAIT", @@ -573,7 +573,7 @@ "category": "async", "trigger": "Async computation first read a signal/memo/store key after an `await`; never a dependency (dev; V8 engines only)", "repair": "An async computation read a signal, memo, or store property for the first time after an `await`.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#untracked_read_after_await" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/untracked-read-after-await" }, { "code": "WASTED_RECOMPUTE", @@ -582,7 +582,7 @@ "category": "perf", "trigger": "80%+ of a scope's 5+ runs in a second produced an unchanged value for 2ms+ of compute — inputs change, result does not (attribution enabled)", "repair": "The mirror of `UNSTABLE_MEMO_OUTPUT`: this scope's equality gate closes almost every time.", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#wasted_recompute" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/wasted-recompute" }, { "code": "WIDE_SCOPE_DEPS", @@ -591,6 +591,6 @@ "category": "perf", "trigger": "Scope subscribed to 200+ sources (attribution cost checks on)", "repair": "Attribution engine cost checks only: a scope is subscribed to 200 or more sources (it re-warns on 50% further growth).", - "guideUrl": "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#wide_scope_deps" + "guideUrl": "https://v2.solidjs.com/reference/solid-js/diagnostics/wide-scope-deps" } ] diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md index ad61f02dea..9403fc58e6 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -23,6 +23,7 @@ computations by their `name` option or the `effect`/`computed` default: ``` [HOT_SCOPE_TIME] effect "effect" spent 12.4ms of compute inside one 1000ms window ... in › › › effect + at src/components/TodoRow.tsx:18:3 ``` Read it as the component tree down to the scope. In captured artifacts the @@ -31,6 +32,16 @@ subject has no named owner — a top-level scope, or an unowned primitive, which for the `NO_OWNER_*` codes is the finding itself. Naming your memos and effects (`{ name }`) turns the trailing `effect` into something you can grep. +The `at` line is the source location (`file:line:col`, relative to the +project root) of the innermost located node — the binding, memo, effect, +signal or store the finding is about, else the JSX site of the nearest +enclosing component. It is written by the compiler's `sourceNames` +`locations` kind (on in a dev build; an opt-in for `observe` builds) and is +absent when the build did not write one. In a browser it prints as the dev +server's URL (`at http://localhost:5173/src/components/TodoRow.tsx:18:3`), +which Chrome and Firefox link to the Sources panel; on the server it is the +bare path. In captured artifacts the same string is `event.loc`. + In a browser, a report about a compiled JSX binding effect (an attribute, class, style, or insert) also carries the element it writes as a second console argument — hover to highlight it on the page, click to jump to it in @@ -38,10 +49,11 @@ the Elements panel. That is the fastest way from a `HOT_SCOPE_*` warning to "which row on screen." Why-chains (`attribution.enable()` from `solid-js/attribution`) print as collapsed console groups: one headline per run, the causes inside. -The first report of each code ends with a footer pointing here — the -installed copy (`node_modules/solid-js/skills/...`) for tooling in the repo, -and the same file on GitHub with an anchor to the code's section for a human -reading the console. +The first report of each code ends with a two-line footer: for a human, the +code's page on the docs site (what the finding means and how to fix it); for +an agent, a pointer here — the installed copy (`node_modules/solid-js/skills/...`) +for tooling in the repo, and the same file on GitHub — anchored to the code's +section. With attribution enabled, every root write also carries WHO performed it (`ChangeRecord.origin`): the user event and element (`click on button#next diff --git a/packages/solid/skills/solid-2.0/SKILL.md b/packages/solid/skills/solid-2.0/SKILL.md index 673b82520f..73bb24ae54 100644 --- a/packages/solid/skills/solid-2.0/SKILL.md +++ b/packages/solid/skills/solid-2.0/SKILL.md @@ -84,7 +84,9 @@ Also: - Full reference: `node_modules/solid-js/CHEATSHEET.md`; the same text is the `solid-2.0-cheatsheet` skill in `node_modules/solid-js/skills/`. - Diagnostics: dev builds print `[CODE] message` plus an `in › …` - owner line. The first report of each code ends with a footer naming + owner line and, when the build wrote source locations, an + `at src/App.tsx:18:3` line. The first report of each code ends with a + footer: the code's docs page, then `node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md`, one section per code (`STRICT_READ_UNTRACKED`, `REACTIVE_WRITE_IN_OWNED_SCOPE`, `LOADING_ON_OUTSIDE_HOLD`, …). Fix the cause; never suppress a code. diff --git a/packages/solid/src/client/component.ts b/packages/solid/src/client/component.ts index 70c072c322..82f04902fd 100644 --- a/packages/solid/src/client/component.ts +++ b/packages/solid/src/client/component.ts @@ -76,13 +76,20 @@ export type Ref = T | ((val: T) => void) | undefined | Ref[]; * the component's owner with it — `Comp.name` is whatever the minifier left * — so diagnostics and attribution paths read `` in production * bundles. The production build ignores it. + * @param loc the JSX site as written in source (`"src/App.tsx:12:5"`, + * root-relative `file:line:col`), emitted by the compiler's `sourceNames` + * `locations` kind beside `name`. Dev and observe builds keep it on the + * component's owner, so a diagnostic raised inside the component reports + * `at src/App.tsx:12:5` when nothing closer was located. The production + * build ignores it. */ export function createComponent>( Comp: Component, props: T, - name?: string + name?: string, + loc?: string ): SolidElement { - if (IS_OBSERVE) return observedComponent(Comp, props || ({} as T), name); + if (IS_OBSERVE) return observedComponent(Comp, props || ({} as T), name, loc); return untrack(() => Comp(props || ({} as T))); } diff --git a/packages/solid/src/client/core.ts b/packages/solid/src/client/core.ts index a7108668f8..9779249c89 100644 --- a/packages/solid/src/client/core.ts +++ b/packages/solid/src/client/core.ts @@ -238,7 +238,12 @@ export interface ComponentRecord

{ task: ConsoleTask | undefined; } -export function observedComponent(Comp: (props: P) => V, props: P, name?: string): V { +export function observedComponent( + Comp: (props: P) => V, + props: P, + name?: string, + loc?: string +): V { // A JSX tag whose component resolved to a non-function otherwise surfaces // as `Cannot read properties of undefined (reading 'name')` from inside the // dev build — a framework-shaped stack for an app-shaped mistake (#3005). @@ -258,8 +263,11 @@ export function observedComponent(Comp: (props: P) => V, props: P, name?: // signals observe layer reads for owner names (reserved from property // mangling there for exactly this write) — so diagnostics locate scopes // by component and an owned-scope write in a component body reports - // "(in )". + // "(in )". The JSX site rides beside it in the `_loc` slot + // (reserved the same way): the fallback location of every finding + // raised under this component that has no closer one. owner._name = label; + owner._loc = loc; if (IS_DEV) { // The JSX site as a console task (Chrome's async stack tagging): // an observer that later reports on this component's nodes — the diff --git a/packages/solid/src/console-footer.ts b/packages/solid/src/console-footer.ts index cfc91479d8..03050031c0 100644 --- a/packages/solid/src/console-footer.ts +++ b/packages/solid/src/console-footer.ts @@ -11,12 +11,14 @@ // code that does fire) are the moments to teach that deeper evidence is one // call away. Dev-tier: the footer is console text. // -// Both pointers are given twice: the installed file (what an agent working in -// the repo can open with no network, at exactly the installed version) and a -// stable URL (what a human in a browser console can click; Chrome linkifies -// it) — the anchor jumps to the code's own section. The URL is the core's -// (`DEV.guideUrl`), so the footer and an observer's link (the performance -// tracks' Insights entry) name the same place. +// Two lines, two readers. The first is for the human at the console: the +// code's page on the docs site — what the finding means and how to fix it — +// the core's `DEV.guideUrl`, so the footer and an observer's link (the +// performance tracks' Insights entry) name the same page. The second is for +// an agent: the repair skill, given twice — the installed file (openable in +// the repo with no network, at exactly the installed version) and the same +// file on GitHub (clickable in a browser console; Chrome linkifies it) — with +// an anchor to the code's own section. // // Installed by both entries — the client's and the server's — so a server // render's console report (a `SERVER_WRITE`, a `HEAD_TAG_INVALID`) carries the @@ -27,14 +29,18 @@ import { DEV, setConsoleFooter } from "@solidjs/signals"; const SKILLS_URL = "https://github.com/solidjs/solid/blob/next/packages"; +const SKILL_PATH = "solid-js/skills/reactivity-diagnostics/SKILL.md"; /** Dev-only; the callers gate on their build's dev literal, where `DEV` is defined. */ export function installConsoleFooter(): void { const guideUrl = DEV!.guideUrl; setConsoleFooter(event => { + // GitHub heading anchors: the code lowercased, underscores kept. + const anchor = `#${event.code.toLowerCase()}`; const base = - `[${event.code}] repair guide: node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md ` + - `— ${guideUrl(event.code)}`; + `[${event.code}] what it means and how to fix it: ${guideUrl(event.code)}` + + `\n[${event.code}] agent repair guide: node_modules/${SKILL_PATH}${anchor} ` + + `— ${SKILLS_URL}/solid/skills/reactivity-diagnostics/SKILL.md${anchor}`; return event.kind === "perf" || event.kind === "graph" || event.kind === "responsiveness" ? base + `\n[${event.code}] deeper evidence: import { attribution } from "solid-js/attribution"; ` + diff --git a/packages/solid/src/server/component.ts b/packages/solid/src/server/component.ts index f85607e9bd..03f8e8c678 100644 --- a/packages/solid/src/server/component.ts +++ b/packages/solid/src/server/component.ts @@ -71,9 +71,10 @@ export type Ref = T | ((val: T) => void); export function createComponent>( Comp: Component, props: T, - _name?: string + _name?: string, + _loc?: string ): SolidElement { - if (IS_OBSERVE) return observedComponent(Comp, props, _name); + if (IS_OBSERVE) return observedComponent(Comp, props, _name, _loc); return Comp(props || ({} as T)); } @@ -89,14 +90,17 @@ export function createComponent>( * * `name` is the source tag the compiler emitted under `sourceNames.components`; it * wins over `Comp.name`, which a minifier rewrites and a `lazy()` wrapper - * hides. The dev tier adds the client's non-function check, so a JSX tag - * that resolved to `undefined` names the mistake instead of failing inside - * the runtime. + * hides. `loc` is the JSX site (`file:line:col`) the compiler emits beside + * it under the `locations` kind; the owner carries it as `_loc`, the + * fallback location of every finding raised under the component. The dev + * tier adds the client's non-function check, so a JSX tag that resolved to + * `undefined` names the mistake instead of failing inside the runtime. */ function observedComponent>( Comp: Component, props: T, - name?: string + name?: string, + loc?: string ): SolidElement { if (IS_DEV && typeof Comp !== "function") { throw new Error( @@ -107,7 +111,7 @@ function observedComponent>( } if (!getOwner()) return Comp(props || ({} as T)); name ||= Comp.name; - return runWithOwner(createComponentOwner(`<${name || "Anonymous"}>`), () => + return runWithOwner(createComponentOwner(`<${name || "Anonymous"}>`, loc), () => Comp(props || ({} as T)) ); } diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index b65115d070..ec02965f52 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -166,6 +166,14 @@ interface SSROwner { * signals node shapes), so the 10-field layout above is untouched there. */ _name?: string; + /** + * Observe/dev tiers only: the component's JSX site (`file:line:col`) the + * compiler emitted beside its name — the core's `emitDiagnostic` walks + * `_parent` for the first one, so a server finding carries `loc` the way + * a client one does. A slot beside `_name`, for the same hidden-class + * reason. + */ + _loc?: string; } const defaultSSRContext: Record = {}; @@ -293,9 +301,10 @@ export function createOwner(options?: { id?: string; transparent?: boolean }): O * observe tier and binds live in prod would be a tier-dependent render. * Never called from the prod build. */ -export function createComponentOwner(name: string): Owner { +export function createComponentOwner(name: string, loc?: string): Owner { const owner = allocateOwner(undefined, true); owner._name = name; + owner._loc = loc; return owner as unknown as Owner; } @@ -330,10 +339,13 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS owner._nextSibling = null; owner._disposed = false; owner._scopeSlot = -1; - if (IS_OBSERVE) owner._name = undefined; + if (IS_OBSERVE) { + owner._name = undefined; + owner._loc = undefined; + } } else { // Two literals, one per tier: the observe/dev shape carries the label - // slot so every owner of the tier shares a hidden class (a pooled + // and location slots so every owner of the tier shares a hidden class (a pooled // component owner reused as a plain one included); prod's 10-field // literal is the same shape in every allocation site. owner = IS_OBSERVE @@ -348,7 +360,8 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS _nextSibling: null, _disposed: false, _scopeSlot: -1, - _name: undefined + _name: undefined, + _loc: undefined } : { id, diff --git a/packages/solid/test/cross-package-fields.spec.ts b/packages/solid/test/cross-package-fields.spec.ts index 3d76bbd975..f80ed47bc5 100644 --- a/packages/solid/test/cross-package-fields.spec.ts +++ b/packages/solid/test/cross-package-fields.spec.ts @@ -63,14 +63,25 @@ function fieldAccesses(code: string): Map { describe("cross-package _-fields", () => { const reserved = reservedFields(); - test("the mangler reserves the two owner fields downstream packages walk", () => { - expect(reserved).toEqual(expect.arrayContaining(["_name", "_parent"])); + test("the mangler reserves the owner fields downstream packages walk or write", () => { + expect(reserved).toEqual(expect.arrayContaining(["_name", "_loc", "_parent"])); }); test("the reserved fields survive in the mangled signals artifacts; a private one does not", () => { for (const tier of ["prod", "observe"]) { const core = readFileSync(join(SIGNALS, `dist/${tier}/core/core.js`), "utf8"); - for (const field of reserved) expect(core, `${tier}: ${field}`).toMatch(`.${field}`); + // A reserved field is read wherever the core walks it — `_loc` only in + // the diagnostics chunk (`dev.ts`), so the check spans the tier's core. + // `_loc` is an observe-tier field end to end (the slot is in the + // observe literals, the walk in `emitDiagnostic`): prod has no + // occurrence to survive. + const tree = jsFiles(join(SIGNALS, `dist/${tier}/core`)) + .map(f => readFileSync(f, "utf8")) + .join("\n"); + for (const field of reserved) { + if (tier === "prod" && field === "_loc") continue; + expect(tree, `${tier}: ${field}`).toMatch(`.${field}`); + } // `_firstChild` is the owner tree's other link and is private: its // absence proves the mangler ran on this file at all. expect(core, `${tier}: _firstChild should be mangled`).not.toMatch("._firstChild"); diff --git a/packages/solid/test/server/console-footer.spec.ts b/packages/solid/test/server/console-footer.spec.ts index 78b5c68a64..7058c1c5c5 100644 --- a/packages/solid/test/server/console-footer.spec.ts +++ b/packages/solid/test/server/console-footer.spec.ts @@ -1,8 +1,9 @@ /** @vitest-environment node */ -// The server entry installs the same repair-guide footer as the client's +// The server entry installs the same footer as the client's // (src/console-footer.ts): the first console report of each code on a server -// render ends with the skill pointer and the code's anchor. Once per code per -// process, appended to the report's own console call. +// render ends with the docs page for a human and the skill pointer (with the +// code's anchor) for an agent. Once per code per process, appended to the +// report's own console call. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { DEV, OBSERVE } from "../../src/server/index.js"; @@ -31,16 +32,20 @@ describe("repair-guide footer (server entry)", () => { report(); expect(warn).toHaveBeenCalledTimes(1); const first = String(warn.mock.calls[0][0]); - expect(first).toContain("[REVEAL_IN_RENDER_TO_STRING] probe"); - expect(first).toContain( - "[REVEAL_IN_RENDER_TO_STRING] repair guide: node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md" + expect(first).toBe( + "[REVEAL_IN_RENDER_TO_STRING] probe\n" + + "[REVEAL_IN_RENDER_TO_STRING] what it means and how to fix it: " + + "https://v2.solidjs.com/reference/solid-js/diagnostics/reveal-in-render-to-string\n" + + "[REVEAL_IN_RENDER_TO_STRING] agent repair guide: " + + "node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md#reveal_in_render_to_string — " + + "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md#reveal_in_render_to_string" ); - expect(first).toContain("SKILL.md#reveal_in_render_to_string"); // Not a perf/graph/responsiveness code: no attribution pointer. expect(first).not.toContain("deeper evidence"); report(); expect(warn).toHaveBeenCalledTimes(2); expect(String(warn.mock.calls[1][0])).not.toContain("repair guide"); + expect(String(warn.mock.calls[1][0])).not.toContain("how to fix it"); }); }); diff --git a/packages/solid/test/server/server-diagnostics.spec.ts b/packages/solid/test/server/server-diagnostics.spec.ts index b56c5f8e42..5443d040f1 100644 --- a/packages/solid/test/server/server-diagnostics.spec.ts +++ b/packages/solid/test/server/server-diagnostics.spec.ts @@ -79,6 +79,39 @@ describe("component labels (observe/dev)", () => { expect(byCode("INVARIANT_VIOLATION")[0].ownerPath).toEqual([""]); }); + test("the compiler's loc argument lands on the component owner; a finding inside carries it", () => { + function Page() { + return probe(); + } + function App() { + // A nested component the compiler did not locate inherits the + // nearest located owner's site. + return createComponent(Page, {}); + } + createRoot(() => createComponent(App, {}, "App", "src/App.tsx:12:5"), { id: "r" }); + const [event] = byCode("INVARIANT_VIOLATION"); + expect(event.ownerPath).toEqual(["", ""]); + expect(event.loc).toBe("src/App.tsx:12:5"); + + capture.clear(); + createRoot( + () => + createComponent( + () => createComponent(Page, {}, "Page", "src/Page.tsx:1:1"), + {}, + "App", + "src/App.tsx:12:5" + ), + { id: "r" } + ); + expect(byCode("INVARIANT_VIOLATION")[0].loc).toBe("src/Page.tsx:1:1"); + + // A pooled owner reused for an unlocated component carries no stale site. + capture.clear(); + createRoot(() => createComponent(App, {}, "App"), { id: "r" }); + expect(byCode("INVARIANT_VIOLATION")[0].loc).toBeUndefined(); + }); + test("the label owner is transparent: hydration ids are the same with and without it", () => { const ids: string[] = []; createRoot( diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 0a47bd31f6..a44ee0a051 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -35,7 +35,7 @@ import { SOURCE_PROXY, SOURCE_MEMO } from "solid-js/internal"; -import { effect, memo, setSpreadName, spreadName, tagElement } from "./render.js"; +import { effect, memo, setSpreadName, spreadLoc, spreadName, tagElement } from "./render.js"; import { JSX } from "../jsx/jsx.js"; @@ -1101,14 +1101,16 @@ export function spread( sources: unknown[], skipChildren?: Boolean, skip?: (key: string) => boolean, - name?: string + name?: string, + loc?: string ): void; export function spread( node: Element, accessor: T, skipChildren?: Boolean, skip?: (key: string) => boolean, - name?: string + name?: string, + loc?: string ): void; // At most TWO reactive nodes per element (#3388) — one when nothing flows @@ -1150,15 +1152,17 @@ export function spread( // and the children insert `.children`, like a compiled hole's. Dev and // observe runtimes carry the labels — through `spreadName` (render.ts), // which `effect` and `insert` read while this body runs, so no call site -// below changes shape and production is byte-identical without them. -export function spread(node, props, skipChildren, skip, name) { +// below changes shape and production is byte-identical without them. `loc` +// (the `locations` kind, one more trailing argument) rides the same way. +export function spread(node, props, skipChildren, skip, name, loc) { if ("_SOLID_OBSERVE_" && name !== undefined) { const outer = spreadName; - setSpreadName(name); + const outerLoc = spreadLoc; + setSpreadName(name, loc); try { return spread(node, props, skipChildren, skip); } finally { - setSpreadName(outer); + setSpreadName(outer, outerLoc); } } const prevProps = {}; @@ -1568,6 +1572,13 @@ export function insert( * observe runtimes carry it on the node; production ignores it. */ name?: string; + /** + * Source location of the hole (`src/App.tsx:14:7`, root-relative + * `file:line:col`) — the compiler's `sourceNames` `locations` kind. Dev + * and observe runtimes carry it on the node for console reports and + * diagnostic events; production ignores it. + */ + loc?: string; } ): JSX.Element; @@ -1579,7 +1590,7 @@ export function insert(parent, accessor, marker, initial, options) { spreadName !== undefined && (options === undefined || options.name === undefined) ) - options = { ...options, name: spreadName + ".children" }; + options = { ...options, name: spreadName + ".children", loc: spreadLoc }; const multi = marker !== undefined; const host = options && options.host; if (multi && !initial) initial = []; diff --git a/packages/web/src/render.ts b/packages/web/src/render.ts index bfd562c8a1..9c1863516e 100644 --- a/packages/web/src/render.ts +++ b/packages/web/src/render.ts @@ -15,8 +15,11 @@ const transparentOptions = { transparent: true, sync: true }; // trailing `undefined` argument in the production artifact. Synchronous: // `spread` creates its nodes before it returns and restores the outer value. export let spreadName: string | undefined; -export function setSpreadName(name: string | undefined): void { +// Its source site, carried the same way (`loc` on the nodes the body creates). +export let spreadLoc: string | undefined; +export function setSpreadName(name: string | undefined, loc?: string): void { spreadName = name; + spreadLoc = loc; } const syncOptions = { sync: true }; @@ -36,19 +39,21 @@ export function tagElement(node: Node): void { // render effect non-transparent so the hole gets its own id scope, mirroring // the server's ssrScope owner. `name` is the binding's target as the // compiler wrote it under `sourceNames.bindings` (`span.textContent`, -// `div.class:active`); the dev and observe runtimes label the effect node -// with it, production ignores it. +// `div.class:active`) and `loc` its source site (`src/App.tsx:14:7`, the +// `locations` kind); the dev and observe runtimes carry both on the effect +// node (the options spread straight into the node's), production ignores +// them. export function effect( fn: (prev?: T) => T, effectFn: (value: T, prev?: T) => void | (() => void), - options?: { scope?: boolean; name?: string } + options?: { scope?: boolean; name?: string; loc?: string } ): void { if ( IS_OBSERVE && spreadName !== undefined && (options === undefined || options.name === undefined) ) - options = { ...options, name: spreadName + ".spread" }; + options = { ...options, name: spreadName + ".spread", loc: spreadLoc }; const nodeOptions = options ? { sync: true, ...options, transparent: !options.scope } : transparentOptions; diff --git a/packages/web/test/binding-names.spec.tsx b/packages/web/test/binding-names.spec.tsx index b0307a6808..f56c1256f9 100644 --- a/packages/web/test/binding-names.spec.tsx +++ b/packages/web/test/binding-names.spec.tsx @@ -1,16 +1,83 @@ /** - * The runtime half of `sourceNames.bindings`: the compiled `{ name }` options - * argument on `effect`/`insert` and the trailing tag on `spread` land on the - * render effect nodes the runtime creates, where the dev and observe tiers - * read them as the node's label (`_name`). + * The runtime half of `sourceNames.bindings` (and its `locations` kind): the + * compiled `{ name, loc }` options argument on `effect`/`insert` and the + * trailing tag and site on `spread` land on the render effect nodes the + * runtime creates, where the dev and observe tiers read them as the node's + * label (`_name`) and source location (`_loc`). */ import { describe, expect, test } from "vitest"; -import { createRoot, createSignal, getOwner, flush } from "solid-js"; +import { createRoot, createSignal, getOwner, flush, OBSERVE } from "solid-js"; import { effect, insert, spread } from "@solidjs/web"; function ownerName(): string | undefined { return (getOwner() as { _name?: string } | null)?._name; } +function ownerLoc(): string | undefined { + return (getOwner() as { _loc?: string } | null)?._loc; +} + +describe("sourceNames.locations at runtime", () => { + test("effect and insert carry the compiled loc onto their nodes; a finding inside reports it", () => { + const seen: (string | undefined)[] = []; + let event: any; + const div = document.createElement("div"); + createRoot(() => { + effect( + () => { + seen.push(ownerLoc()); + event = OBSERVE!.diagnostics.emit( + { code: "INVARIANT_VIOLATION", kind: "error", severity: "error", message: "probe" }, + getOwner() + ); + return 1; + }, + () => {}, + { name: "span.textContent", loc: "src/App.tsx:14:7" } + ); + insert( + div, + () => { + seen.push(ownerLoc()); + return "x"; + }, + undefined, + undefined, + { name: "div.children", loc: "src/App.tsx:15:9" } + ); + effect( + () => { + seen.push(ownerLoc()); + return 1; + }, + () => {} + ); + }); + flush(); + expect(seen).toEqual(["src/App.tsx:14:7", "src/App.tsx:15:9", undefined]); + expect(event.loc).toBe("src/App.tsx:14:7"); + expect(event.ownerPath).toEqual(["span.textContent"]); + }); + + test("spread's trailing loc lands on its attribute effect and children insert", () => { + const seen = new Set(); + const div = document.createElement("div"); + createRoot(() => { + spread( + div, + () => { + seen.add(ownerLoc()); + return { title: "t", children: () => (seen.add(ownerLoc()), "x") }; + }, + false, + undefined, + "div", + "src/App.tsx:20:3" + ); + }); + flush(); + expect([...seen]).toEqual(["src/App.tsx:20:3"]); + }); +}); describe("sourceNames.bindings at runtime", () => { test("effect carries the compiled name onto its node", () => { diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index 9dffcbcfad..edfc2c64c2 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -1578,7 +1578,7 @@ describe("enablePerformanceTracks", () => { ["Node", "results"], ["Message", 'click on button#save "Save" waited 412ms with no feedback'], ["holdMs", "412"], // primitive `data` fields ride along; objects do not - ["Guide", expect.stringMatching(/SKILL\.md#silent_hold$/)] + ["Guide", "https://v2.solidjs.com/reference/solid-js/diagnostics/silent-hold"] ]) ); expect(hold.properties!.some(([k]) => k === "interaction")).toBe(false); @@ -1586,7 +1586,7 @@ describe("enablePerformanceTracks", () => { name: "Solid: SILENT_HOLD", severity: "warning", description: 'click on button#save "Save" waited 412ms with no feedback', - learnMoreUrl: expect.stringMatching(/SKILL\.md#silent_hold$/) + learnMoreUrl: "https://v2.solidjs.com/reference/solid-js/diagnostics/silent-hold" }); // `info` is a marker and nothing more. expect(hot).toMatchObject({ label: "HOT_SCOPE_RERUNS", color: "primary-light" }); diff --git a/scripts/generate-diagnostics-json.mjs b/scripts/generate-diagnostics-json.mjs index 9b13ff2c19..6c7222f666 100644 --- a/scripts/generate-diagnostics-json.mjs +++ b/scripts/generate-diagnostics-json.mjs @@ -19,7 +19,7 @@ * `BINDING_SLOT_POSITION` reports under more than one kind); `severity` is * the RFC's Severity cell split the same way into `DiagnosticSeverity` values * (`["info", "warn"]` when the tier depends on the finding); `guideUrl` is the - * code's section in the skill on GitHub. + * code's page on the docs site (what `DEV.guideUrl(code)` returns). * * Plain Node, no dependencies. Run from anywhere: * @@ -37,8 +37,8 @@ export const DEV_TS = "packages/signals/src/core/dev.ts"; export const RFC_MD = "documentation/solid-2.0/08-dev-diagnostics.md"; export const SKILL_MD = "packages/solid/skills/reactivity-diagnostics/SKILL.md"; export const OUTPUT_JSON = "packages/solid/diagnostics.json"; -export const GUIDE_BASE_URL = - "https://github.com/solidjs/solid/blob/next/packages/solid/skills/reactivity-diagnostics/SKILL.md"; +/** The docs site's page per code — the same URL `DEV.guideUrl` builds. */ +export const GUIDE_BASE_URL = "https://v2.solidjs.com/reference/solid-js/diagnostics/"; const RFC_HEADING = "## Diagnostic codes (quick reference)"; @@ -212,7 +212,7 @@ function splitCell(cell, allowed, aliases, what, code, file) { } export function guideUrl(code) { - return `${GUIDE_BASE_URL}#${code.toLowerCase()}`; + return GUIDE_BASE_URL + code.toLowerCase().replace(/_/g, "-"); } /** Read the three sources from `root`. */ From 26a49f0aaa32acf6595593d3e56f00de4a2d4549 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 15 Oct 2026 21:58:05 +0000 Subject: [PATCH 2/6] feat(babel-plugin): sourceNames.locations carries file:line:col MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third `sourceNames` kind, `locations`, and a `root` option. Where a component or binding label is emitted, its source location rides beside it: `createComponent(Home, props, "Home", "src/App.tsx:12:5")` (the ` Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY --- .changeset/babel-source-names-locations.md | 5 + packages/babel-plugin/README.md | 10 +- packages/babel-plugin/src/config.ts | 28 ++++- packages/babel-plugin/src/dom/element.ts | 29 +++-- packages/babel-plugin/src/dom/template.ts | 23 ++-- packages/babel-plugin/src/shared/component.ts | 5 + packages/babel-plugin/src/shared/location.ts | 73 ++++++++++++ packages/babel-plugin/src/types.ts | 6 + .../walkValidation/output.js | 12 +- .../bindings/output.js | 65 +++++++--- .../components/output.js | 60 ++++++++-- .../locations/code.js | 27 +++++ .../locations/options.json | 3 + .../locations/output.js | 111 ++++++++++++++++++ .../ssr/output.js | 55 +++++++-- .../test/dom-source-names.spec.js | 64 +++++++++- 16 files changed, 506 insertions(+), 70 deletions(-) create mode 100644 .changeset/babel-source-names-locations.md create mode 100644 packages/babel-plugin/src/shared/location.ts create mode 100644 packages/babel-plugin/test/__dom_source_names_fixtures__/locations/code.js create mode 100644 packages/babel-plugin/test/__dom_source_names_fixtures__/locations/options.json create mode 100644 packages/babel-plugin/test/__dom_source_names_fixtures__/locations/output.js diff --git a/.changeset/babel-source-names-locations.md b/.changeset/babel-source-names-locations.md new file mode 100644 index 0000000000..645c92f799 --- /dev/null +++ b/.changeset/babel-source-names-locations.md @@ -0,0 +1,5 @@ +--- +"@solidjs/babel-plugin": patch +--- + +`sourceNames` gains a `locations` kind (and the plugin a `root` option): component and binding labels carry a `file:line:col` for diagnostics. `createComponent(Home, props, "Home", "src/App.tsx:12:5")` points at the `` compiles to `createComponent(Home, props, "Home")`, `` to `"Ui.Button"` — so each component's owner reads `` even when a `lazy()`/HMR wrapper hides the function. Applies to DOM and SSR output; for SSR the compiler keeps the `createComponent` call it otherwise inlines to `Comp(props)`, so the server runtime labels the owner the same way (prod SSR output, without the option, is unchanged). Universal and dynamic output are unaffected. - `bindings`: every compiled binding effect is named by what it writes, as a trailing options argument the dev and observe runtimes read and production ignores. An attribute effect gets `.` as written — `` compiles to `effect(() => label(), v => …, { name: "span.textContent" })`, `class:active` to `div.class:active`, a `style={{ color: c() }}` property to `div.style:color` — and a template's merged effect lists all of its bindings (`"button.class, span.textContent"`). A hole's insert is named for the parent it fills: `

{count()}
` compiles to `insert(el, count, undefined, undefined, { name: "div.children" })`; a static child (a component call, a literal) creates no effect and gets no name. A spread passes the tag as its trailing argument (`spread(el, props, false, undefined, "div")`), and the runtime labels its attribute effect `div.spread` and its children insert `div.children`. DOM output only. +- `locations`: where the JSX is written, beside each label the other two kinds emit, as `file:line:col` — the module path relative to [`root`](#root) with `/` separators, line and column 1-based (the stack-trace convention, so a dev server links `http://localhost:5173/src/App.tsx:18:3` to the right place). A component call gets it as a fourth argument: `` compiles to `createComponent(Home, props, "Home", "src/App.tsx:12:5")`, the location of ` { const value = config.sourceNames ?? config.dev; - if (typeof value === "boolean") return { components: value, bindings: value }; - return { components: value.components ?? config.dev, bindings: value.bindings ?? config.dev }; + if (typeof value === "boolean") return { components: value, bindings: value, locations: value }; + return { + components: value.components ?? config.dev, + bindings: value.bindings ?? config.dev, + locations: value.locations ?? config.dev + }; } /** diff --git a/packages/babel-plugin/src/dom/element.ts b/packages/babel-plugin/src/dom/element.ts index 7611fbd4be..1e72f4138e 100644 --- a/packages/babel-plugin/src/dom/element.ts +++ b/packages/babel-plugin/src/dom/element.ts @@ -38,6 +38,7 @@ import { import { transformNode } from "../shared/transform"; import { decodedAttrValue } from "../universal/element"; import { namesBindings } from "../config"; +import { bindingOptions, sourceLocation } from "../shared/location"; import { InlineElements, BlockElements } from "./constants"; import type { BabelPath, @@ -713,6 +714,8 @@ function transformAttributes( t.jsxExpressionContainer(p.value as babelTypes.Expression) ); (newAttr as babelTypes.JSXAttribute & { _styleProperty?: boolean })._styleProperty = true; + // The split-out property is located at the `style` attribute it came from. + newAttr.loc = styleObjectAttribute.node.loc; path .get("openingElement") .node.attributes.splice(Number(styleObjectAttribute.key) + ++i, 0, newAttr); @@ -815,6 +818,8 @@ function transformAttributes( t.jsxExpressionContainer(p.value as babelTypes.Expression) ); (newAttr as babelTypes.JSXAttribute & { _classProperty?: boolean })._classProperty = true; + // The split-out property is located at the `class` attribute it came from. + newAttr.loc = classListAttribute.node.loc; path .get("openingElement") .node.attributes.splice(Number(classListAttribute.key) + ++i, 0, newAttr); @@ -1223,7 +1228,11 @@ function transformAttributes( tagName, checkableInput, styleProperty: isStyleProperty, - classProperty: isClassProperty + classProperty: isClassProperty, + // `sourceNames.locations`: the attribute's start (for a property + // split out of a `style={{…}}` / `class={{…}}` object, the + // object attribute's start — see the pre-passes above). + loc: sourceLocation(attribute, attribute.node) }); } else { results.exprs.push( @@ -1384,6 +1393,8 @@ function transformChildren( if (!transformed) return memo; (transformed as TransformResult & { allocatesIds?: boolean }).allocatesIds = allocatesIds; (transformed as TransformResult & { functionHole?: boolean }).functionHole = functionHole; + // `sourceNames.locations`: where the hole is written, for its insert. + if (child.isJSXExpressionContainer()) transformed.loc = sourceLocation(child, child.node); const i = memo.length; if (transformed.text && i && memo[i - 1].text) { memo[i - 1].template = @@ -1467,14 +1478,11 @@ function transformChildren( // is inserted directly and creates no effect to name. const accessorHole = child.dynamic || (child as TransformResult & { functionHole?: boolean }).functionHole; + // `sourceNames.locations`: `loc` after `name`, the hole's `{` start. const emitInsert = (args: babelTypes.Expression[]) => { if (accessorHole && namesBindings(getConfig(path))) { while (args.length < 4) args.push(t.identifier("undefined")); - args.push( - t.objectExpression([ - t.objectProperty(t.identifier("name"), t.stringLiteral(`${tagName}.children`)) - ]) - ); + args.push(bindingOptions(`${tagName}.children`, child.loc)); } results.exprs.push(t.expressionStatement(t.callExpression(insert, args))); }; @@ -1774,7 +1782,14 @@ function processSpreads( // `sourceNames.bindings`: the tag as written rides as spread's trailing // argument (past the runtime-only `skip` slot); the runtime labels its // attribute effect `.spread` and its children insert `.children`. - if (namesBindings(config)) args.push(t.identifier("undefined"), t.stringLiteral(tagName)); + // `sourceNames.locations`: the first spread attribute's start (`{...`) + // follows the name as one more string argument. + if (namesBindings(config)) { + args.push(t.identifier("undefined"), t.stringLiteral(tagName)); + const spread = attributes.find(a => t.isJSXSpreadAttribute(a.node)); + const loc = spread && sourceLocation(spread, spread.node); + if (loc !== undefined) args.push(t.stringLiteral(loc)); + } return [ filteredAttributes, diff --git a/packages/babel-plugin/src/dom/template.ts b/packages/babel-plugin/src/dom/template.ts index 189b2dbd58..c1d5aa98f7 100644 --- a/packages/babel-plugin/src/dom/template.ts +++ b/packages/babel-plugin/src/dom/template.ts @@ -12,6 +12,7 @@ import { } from "../shared/utils"; import { setAttr } from "./element"; import { namesBindings } from "../config"; +import { bindingOptions } from "../shared/location"; import type { NodePath } from "@babel/traverse"; import type { DynamicBinding, ProgramScopeData, TemplateRecord, TransformResult } from "../types"; @@ -218,21 +219,19 @@ function wrapDynamics( // binding's `.` as written (undoing the `prop:` the locked // DOM property pre-pass added to `value`, `checked`, …), the merged effect // listing all of its bindings — as a trailing `{ name }` options argument. + // `sourceNames.locations` adds `loc` after it: the first binding's + // attribute start (a merged effect points at the first attribute it lists). const label = namesBindings(config) ? [ - t.objectExpression([ - t.objectProperty( - t.identifier("name"), - t.stringLiteral( - dynamics - .map( - d => - `${d.tagName}.${isLockedDOMProperty(d.tagName, d.key) ? d.key.replace(/^prop:/, "") : d.key}` - ) - .join(", ") + bindingOptions( + dynamics + .map( + d => + `${d.tagName}.${isLockedDOMProperty(d.tagName, d.key) ? d.key.replace(/^prop:/, "") : d.key}` ) - ) - ]) + .join(", "), + dynamics.find(d => d.loc !== undefined)?.loc + ) ] : []; diff --git a/packages/babel-plugin/src/shared/component.ts b/packages/babel-plugin/src/shared/component.ts index c43b861ee5..1db09933c8 100644 --- a/packages/babel-plugin/src/shared/component.ts +++ b/packages/babel-plugin/src/shared/component.ts @@ -14,6 +14,7 @@ import { transformNode, getCreateTemplate, thisTagIdentifiers } from "./transfor import { markPropsLiteral } from "../ssr/props"; import { decodedAttrValue } from "../universal/element"; import { sourceNames, type PluginConfig } from "../config"; +import { sourceLocation } from "./location"; import type { BabelPath, JSXNode, TransformResult } from "../types"; type JSXAttributePath = BabelPath; @@ -370,6 +371,10 @@ export default function transformComponent( sourceNames(config).components && (config.generate === "dom" || config.generate === "ssr"); if (labelled) { componentArgs.push(t.stringLiteral(tagName)); + // `sourceNames.locations`: the opening element's start (`::`, + * the file relative to `config.root` (default: Babel's `cwd`, the working + * directory) with posix separators, line and column both 1-based — the + * stack-trace / editor convention, so a browser links + * `http://localhost:5173/src/App.tsx:18:3` to the right place. Babel's + * `loc.start.column` is 0-based, so it is bumped by one here (the refresh + * pass's `location` keeps Babel's 0-based column; that is a different + * consumer). A file outside `root` yields a path starting with `..`, emitted + * as-is. `undefined` when the kind is off, when Babel was given no `filename` + * for the module, or when `node` is synthetic and carries no `loc`. + * + * `@solidjs/compiler` computes the same string with `relative_id` so both + * compilers print identical output. + */ +export function sourceLocation(path: NodePath, node: t.Node): string | undefined { + const config = getConfig(path); + if (!sourceNames(config).locations) return; + const start = node.loc?.start; + if (!start) return; + const opts = (path.hub as unknown as BabelHubWithMetadata).file?.opts; + const filename = opts?.filename; + if (!filename) return; + const cwd = opts?.cwd ?? ""; + const root = config.root ? resolve(cwd, config.root) : resolve(cwd, cwd); + return `${relative(root, resolve(cwd, filename))}:${start.line}:${start.column + 1}`; +} + +/** + * A binding's `{ name, loc? }` options literal — `name` first, then `loc` + * only when there is one, so both JSX compilers print the same text. + */ +export function bindingOptions(name: string, loc: string | undefined): t.ObjectExpression { + const properties = [t.objectProperty(t.identifier("name"), t.stringLiteral(name))]; + if (loc !== undefined) + properties.push(t.objectProperty(t.identifier("loc"), t.stringLiteral(loc))); + return t.objectExpression(properties); +} + +// Path arithmetic without node's `path` (this plugin bundles for any host): +// the same steps as `@solidjs/compiler`'s `relative_id` — resolve both paths +// against `cwd`, fold `.`/`..`, drop the common prefix. + +/** `p` resolved against `base` (posix separators, `.`/`..` folded), as segments. */ +function resolve(base: string, p: string): string[] { + const posix = p.replace(/\\/g, "/"); + const absolute = /^(\/|[A-Za-z]:\/)/.test(posix); + const joined = absolute ? posix : `${base.replace(/\\/g, "/")}/${posix}`; + const segments: string[] = []; + for (const segment of joined.split("/")) { + if (segment === "." || (segment === "" && segments.length)) continue; + if (segment === "..") { + // Never past the root marker (`""` for `/…`, `C:` for a drive). + if (segments.length > 1) segments.pop(); + continue; + } + segments.push(segment); + } + return segments; +} + +/** `file` relative to `root`, both resolved: `..` per root segment left, then the file's rest. */ +function relative(root: string[], file: string[]): string { + let i = 0; + while (i < root.length && i < file.length && root[i] === file[i]) i++; + return [...root.slice(i).map(() => ".."), ...file.slice(i)].join("/"); +} diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 200ebd2e4b..f483d679d3 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -62,6 +62,8 @@ export type TsrxBabelAst = t.File & { export type BabelFileWithMetadata = { ast: TsrxBabelAst; + /** Babel's normalized file options: `filename` is absent when the caller gave none. */ + opts?: { filename?: string | null; cwd?: string }; metadata: { config?: PluginConfig; css?: string; @@ -110,6 +112,8 @@ export interface TransformResult { spreadElement?: boolean; groupable?: Set; groupId?: t.Identifier; + /** `sourceNames.locations`: a hole's `{` start, `file:line:col` (shared/location.ts). */ + loc?: string; } export type TransformNodeResult = TransformResult | null | undefined; @@ -162,6 +166,8 @@ export interface DynamicBinding { styleProperty?: boolean; classProperty?: boolean; checkableInput?: boolean; + /** `sourceNames.locations`: the binding's attribute start, `file:line:col` (shared/location.ts). */ + loc?: string; } export interface SetAttrOptions { diff --git a/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js b/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js index 97ec3341a7..a1709eae52 100644 --- a/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js @@ -17,7 +17,8 @@ _$insert( undefined, undefined, { - name: "span.children" + name: "span.children", + loc: "test/__dom_hydratable_dev_fixtures__/walkValidation/code.js:5:11" } ); const singleChild = _el$; @@ -30,7 +31,8 @@ _$insert( undefined, undefined, { - name: "main.children" + name: "main.children", + loc: "test/__dom_hydratable_dev_fixtures__/walkValidation/code.js:12:11" } ); const siblingElements = _el$3; @@ -43,7 +45,8 @@ _$insert( undefined, undefined, { - name: "b.children" + name: "b.children", + loc: "test/__dom_hydratable_dev_fixtures__/walkValidation/code.js:19:14" } ); const mixedTextAndElements = _el$6; @@ -56,7 +59,8 @@ _$insert( undefined, undefined, { - name: "li.children" + name: "li.children", + loc: "test/__dom_hydratable_dev_fixtures__/walkValidation/code.js:26:11" } ); const nestedWalk = _el$9; diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/bindings/output.js b/packages/babel-plugin/test/__dom_source_names_fixtures__/bindings/output.js index ebf5b35701..1541398d62 100644 --- a/packages/babel-plugin/test/__dom_source_names_fixtures__/bindings/output.js +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/bindings/output.js @@ -26,7 +26,8 @@ _$effect( _el$2.data = _v$; }, { - name: "span.textContent" + name: "span.textContent", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:2:22" } ); // One binding → its own effect, named `.`. @@ -38,7 +39,8 @@ _$effect( _el$3.value = _v$ ?? ""; }, { - name: "input.value" + name: "input.value", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:3:21" } ); const attr = _el$3; @@ -49,7 +51,8 @@ _$effect( _$className(_el$4, _v$, _$p); }, { - name: "div.class" + name: "div.class", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:4:18" } ); const cls = _el$4; @@ -64,7 +67,8 @@ _$effect( t !== _p$?.t && _$setStyleProperty(_el$5, "font-size", t); }, { - name: "div.style:color, div.style:font-size" + name: "div.style:color, div.style:font-size", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:5:20" } ); const style = _el$5; @@ -77,7 +81,8 @@ _$effect( _$setAttribute(_el$6, "classList", _v$); }, { - name: "div.classList" + name: "div.classList", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:6:24" } ); const classList = _el$6; @@ -92,7 +97,8 @@ _$effect( t !== _p$?.t && _$setAttribute(_el$7, "style:width", t); }, { - name: "div.class:selected, div.style:width" + name: "div.class:selected, div.style:width", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:7:17" } ); const ns = _el$7; @@ -115,7 +121,8 @@ _$effect( (!_p$ || o !== _p$.o) && (_el$0.data = o); }, { - name: "button.class, button.title, button.disabled, span.textContent" + name: "button.class, button.title, button.disabled, span.textContent", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:11:11" } ); const merged = _el$8; @@ -123,7 +130,8 @@ const merged = _el$8; // Holes → insert, named for the parent it fills. var _el$1 = _tmpl$3(); _$insert(_el$1, count, undefined, undefined, { - name: "div.children" + name: "div.children", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:17:19" }); const hole = _el$1; var _el$10 = _tmpl$5(), @@ -133,28 +141,49 @@ var _el$10 = _tmpl$5(), _el$13 = _el$12.nextSibling, _el$15 = _el$13.nextSibling; _$insert(_el$10, name, _el$14, undefined, { - name: "div.children" + name: "div.children", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:20:11" }); _$insert(_el$13, greeting, undefined, undefined, { - name: "p.children" + name: "p.children", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:20:23" }); _$insert(_el$10, list, _el$15, undefined, { - name: "div.children" + name: "div.children", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:21:5" }); const holes = _el$10; const staticChild = _tmpl$6(); var _el$17 = _tmpl$3(); -_$insert(_el$17, _$createComponent(Child, {}, "Child")); +_$insert( + _el$17, + _$createComponent(Child, {}, "Child", "test/__dom_source_names_fixtures__/bindings/code.js:27:5") +); const componentChild = _el$17; // Spreads → the tag rides as spread's trailing argument. var _el$18 = _tmpl$3(); -_$spread(_el$18, props, false, undefined, "div"); +_$spread( + _el$18, + props, + false, + undefined, + "div", + "test/__dom_source_names_fixtures__/bindings/code.js:32:21" +); const spread = _el$18; var _el$19 = _tmpl$7(); -_$spread(_el$19, props, true, undefined, "section"); +_$spread( + _el$19, + props, + true, + undefined, + "section", + "test/__dom_source_names_fixtures__/bindings/code.js:33:37" +); _$insert(_el$19, children, undefined, undefined, { - name: "section.children" + name: "section.children", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:33:48" }); const spreadWithChildren = _el$19; var _el$20 = _tmpl$8(); @@ -173,7 +202,8 @@ _$spread( ], false, undefined, - "a" + "a", + "test/__dom_source_names_fixtures__/bindings/code.js:34:38" ); const spreadMixed = _el$20; @@ -191,7 +221,8 @@ _$effect( _$claimElement(_el$21, _o$); }, { - name: "a.href, a.target" + name: "a.href, a.target", + loc: "test/__dom_source_names_fixtures__/bindings/code.js:38:6" } ); const link = _el$21; diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/components/output.js b/packages/babel-plugin/test/__dom_source_names_fixtures__/components/output.js index 727a67333e..86f156b03d 100644 --- a/packages/babel-plugin/test/__dom_source_names_fixtures__/components/output.js +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/components/output.js @@ -13,7 +13,8 @@ const Component = () => { name: "John" }, - "Child" + "Child", + "test/__dom_source_names_fixtures__/components/code.js:3:25" ); var _el$ = _tmpl$2(), _el$3 = _el$.firstChild, @@ -36,13 +37,15 @@ _$insert( get children() { var _el$2 = _tmpl$(); _$insert(_el$2, name, undefined, undefined, { - name: "span.children" + name: "span.children", + loc: "test/__dom_source_names_fixtures__/components/code.js:8:13" }); return _el$2; } } ), - "Child" + "Child", + "test/__dom_source_names_fixtures__/components/code.js:7:5" ), _el$3 ); @@ -53,7 +56,8 @@ _$insert( { variant: "primary" }, - "Ui.Button" + "Ui.Button", + "test/__dom_source_names_fixtures__/components/code.js:10:5" ), _el$4 ); @@ -65,7 +69,8 @@ _$insert( cols: 2, children: "text" }, - "Ui.Layout.Grid" + "Ui.Layout.Grid", + "test/__dom_source_names_fixtures__/components/code.js:11:5" ), _el$5 ); @@ -83,10 +88,12 @@ _$insert( { item: item }, - "Row" + "Row", + "test/__dom_source_names_fixtures__/components/code.js:12:33" ) }, - "For" + "For", + "test/__dom_source_names_fixtures__/components/code.js:12:5" ), _el$6 ); @@ -99,22 +106,44 @@ _$insert( return visible(); }, get children() { - return _$createComponent(Child, {}, "Child"); + return _$createComponent( + Child, + {}, + "Child", + "test/__dom_source_names_fixtures__/components/code.js:14:7" + ); } }, - "Show" + "Show", + "test/__dom_source_names_fixtures__/components/code.js:13:5" ), _el$7 ); -_$insert(_el$, _$createComponent(_self$.Row, {}, "this.Row"), _el$8); +_$insert( + _el$, + _$createComponent( + _self$.Row, + {}, + "this.Row", + "test/__dom_source_names_fixtures__/components/code.js:16:5" + ), + _el$8 +); _$insert( _el$, _$createComponent( Comp, { - children: () => _$createComponent(Child, {}, "Child") + children: () => + _$createComponent( + Child, + {}, + "Child", + "test/__dom_source_names_fixtures__/components/code.js:17:18" + ) }, - "Comp" + "Comp", + "test/__dom_source_names_fixtures__/components/code.js:17:5" ), _el$9 ); @@ -124,6 +153,11 @@ const template = (() => { })(); class Container { render() { - return _$createComponent(this.Row, {}, "this.Row"); + return _$createComponent( + this.Row, + {}, + "this.Row", + "test/__dom_source_names_fixtures__/components/code.js:23:12" + ); } } diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/code.js b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/code.js new file mode 100644 index 0000000000..63fead893c --- /dev/null +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/code.js @@ -0,0 +1,27 @@ +// `sourceNames.locations`: every label carries where its JSX is written, +// `file:line:col` relative to `root` (here the fixtures directory, resolved +// against the working directory), line and column 1-based. +import { Layout, Card, Row } from "./components"; + +// Nested components → each `createComponent` points at its own ` + + + + +); + +// An attribute binding → the attribute's start; a merged effect points at +// the first binding it lists. +const attribute =
; +const styleObject =
; + +// A hole → the `{` of the expression. +const hole =

Count: {count()}

; + +// A spread → the `{...` of the first spread attribute. +const spread =
; + +// A component inside a conditional → the conditional's memo keeps the label. +const conditional =
{visible() ? : null}
; diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/options.json b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/options.json new file mode 100644 index 0000000000..87d887988f --- /dev/null +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/options.json @@ -0,0 +1,3 @@ +{ + "root": "test/__dom_source_names_fixtures__" +} diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/output.js b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/output.js new file mode 100644 index 0000000000..1ed3687412 --- /dev/null +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/locations/output.js @@ -0,0 +1,111 @@ +import { template as _$template } from "r-dom"; +import { memo as _$memo } from "r-dom"; +import { spread as _$spread } from "r-dom"; +import { insert as _$insert } from "r-dom"; +import { setStyleProperty as _$setStyleProperty } from "r-dom"; +import { setAttribute as _$setAttribute } from "r-dom"; +import { className as _$className } from "r-dom"; +import { readShallow as _$readShallow } from "r-dom"; +import { effect as _$effect } from "r-dom"; +import { createComponent as _$createComponent } from "r-dom"; +var _tmpl$ = /*#__PURE__*/ _$template(`
`), + _tmpl$2 = /*#__PURE__*/ _$template(`

Count: `), + _tmpl$3 = /*#__PURE__*/ _$template(`

`); +// `sourceNames.locations`: every label carries where its JSX is written, +// `file:line:col` relative to `root` (here the fixtures directory, resolved +// against the working directory), line and column 1-based. +import { Layout, Card, Row } from "./components"; + +// Nested components → each `createComponent` points at its own ` ({ + e: _$readShallow(cls()), + t: title() + }), + ({ e, t }, _p$) => { + _$className(_el$, e, _p$?.e); + t !== _p$?.t && _$setAttribute(_el$, "title", t); + }, + { + name: "div.class, div.title", + loc: "locations/code.js:17:24" + } +); +const attribute = _el$; +var _el$2 = _tmpl$(); +_$effect( + () => color(), + _v$ => { + _$setStyleProperty(_el$2, "color", _v$); + }, + { + name: "div.style:color", + loc: "locations/code.js:18:26" + } +); +const styleObject = _el$2; + +// A hole → the `{` of the expression. +var _el$3 = _tmpl$2(), + _el$4 = _el$3.firstChild; +_$insert(_el$3, count, null, undefined, { + name: "p.children", + loc: "locations/code.js:21:24" +}); +const hole = _el$3; + +// A spread → the `{...` of the first spread attribute. +var _el$5 = _tmpl$3(); +_$spread(_el$5, props, false, undefined, "section", "locations/code.js:24:25"); +const spread = _el$5; + +// A component inside a conditional → the conditional's memo keeps the label. +var _el$6 = _tmpl$(); +_$insert( + _el$6, + (() => { + var _c$ = _$memo(() => !!visible()); + return () => + _c$() + ? _$createComponent( + Card, + { + title: "On" + }, + "Card", + "locations/code.js:27:39" + ) + : null; + })(), + undefined, + undefined, + { + name: "div.children", + loc: "locations/code.js:27:26" + } +); +const conditional = _el$6; diff --git a/packages/babel-plugin/test/__dom_source_names_fixtures__/ssr/output.js b/packages/babel-plugin/test/__dom_source_names_fixtures__/ssr/output.js index f25c391b37..7db48fbd51 100644 --- a/packages/babel-plugin/test/__dom_source_names_fixtures__/ssr/output.js +++ b/packages/babel-plugin/test/__dom_source_names_fixtures__/ssr/output.js @@ -14,7 +14,8 @@ const Component = () => { name: "John" }, - "Child" + "Child", + "test/__dom_source_names_fixtures__/ssr/code.js:3:25" ); var _v$2 = _$escape( _$createComponent( @@ -30,7 +31,8 @@ var _v$2 = _$escape( } } ), - "Child" + "Child", + "test/__dom_source_names_fixtures__/ssr/code.js:7:5" ) ), _v$3 = _$escape( @@ -39,7 +41,8 @@ var _v$2 = _$escape( { variant: "primary" }, - "Ui.Button" + "Ui.Button", + "test/__dom_source_names_fixtures__/ssr/code.js:10:5" ) ), _v$4 = _$escape( @@ -49,7 +52,8 @@ var _v$2 = _$escape( cols: 2, children: "text" }, - "Ui.Layout.Grid" + "Ui.Layout.Grid", + "test/__dom_source_names_fixtures__/ssr/code.js:11:5" ) ), _v$5 = _$escape( @@ -65,10 +69,12 @@ var _v$2 = _$escape( { item: item }, - "Row" + "Row", + "test/__dom_source_names_fixtures__/ssr/code.js:12:33" ) }, - "For" + "For", + "test/__dom_source_names_fixtures__/ssr/code.js:12:5" ) ), _v$6 = _$escape( @@ -79,20 +85,40 @@ var _v$2 = _$escape( return visible(); }, get children() { - return _$createComponent(Child, {}, "Child"); + return _$createComponent( + Child, + {}, + "Child", + "test/__dom_source_names_fixtures__/ssr/code.js:14:7" + ); } }, - "Show" + "Show", + "test/__dom_source_names_fixtures__/ssr/code.js:13:5" + ) + ), + _v$7 = _$escape( + _$createComponent( + _self$.Row, + {}, + "this.Row", + "test/__dom_source_names_fixtures__/ssr/code.js:16:5" ) ), - _v$7 = _$escape(_$createComponent(_self$.Row, {}, "this.Row")), _v$8 = _$escape( _$createComponent( Comp, { - children: () => _$createComponent(Child, {}, "Child") + children: () => + _$createComponent( + Child, + {}, + "Child", + "test/__dom_source_names_fixtures__/ssr/code.js:17:18" + ) }, - "Comp" + "Comp", + "test/__dom_source_names_fixtures__/ssr/code.js:17:5" ) ); const template = (() => { @@ -101,6 +127,11 @@ const template = (() => { })(); class Container { render() { - return _$createComponent(this.Row, {}, "this.Row"); + return _$createComponent( + this.Row, + {}, + "this.Row", + "test/__dom_source_names_fixtures__/ssr/code.js:23:12" + ); } } diff --git a/packages/babel-plugin/test/dom-source-names.spec.js b/packages/babel-plugin/test/dom-source-names.spec.js index ff0375d5e2..8f11945716 100644 --- a/packages/babel-plugin/test/dom-source-names.spec.js +++ b/packages/babel-plugin/test/dom-source-names.spec.js @@ -22,7 +22,8 @@ runFixtures({ describe("sourceNames defaults", () => { const babel = require("@babel/core"); const code = "const view =
;"; - const label = '_$createComponent(Home, {}, "Home")'; + // A prefix: with `locations` (also following `dev`) a fourth argument follows. + const label = '_$createComponent(Home, {}, "Home"'; const binding = 'name: "div.class"'; const compile = opts => babel.transformSync(code, { @@ -59,3 +60,64 @@ describe("sourceNames defaults", () => { expect(prodPicked).not.toContain(binding); }); }); + +// `sourceNames.locations` follows `dev` like the other kinds; a location is +// only emitted beside a label, and never without a `filename`. +describe("sourceNames locations defaults", () => { + const babel = require("@babel/core"); + const code = "const view =
;"; + const label = '_$createComponent(Home, {}, "Home", "input.jsx:1:33")'; + const binding = 'loc: "input.jsx:1:19"'; + const compile = (opts, filename = "input.jsx") => + babel.transformSync(code, { + configFile: false, + babelrc: false, + ...(filename ? { filename } : {}), + plugins: [[plugin, { moduleName: "r-dom", ...opts }]] + }).code; + + test("unset follows dev: on in dev, off otherwise", () => { + const dev = compile({ dev: true }); + expect(dev).toContain(label); + expect(dev).toContain(binding); + const prod = compile({ dev: false }); + expect(prod).not.toContain("input.jsx"); + expect(prod).not.toContain("loc:"); + expect(prod).toBe(compile({})); + }); + + test("`{ locations: false }` in dev drops only the locations", () => { + const named = compile({ dev: true, sourceNames: { locations: false } }); + expect(named).toContain('_$createComponent(Home, {}, "Home")'); + expect(named).toContain('name: "div.class"'); + expect(named).not.toContain("input.jsx"); + expect(named).not.toContain("loc:"); + expect(named).toBe( + compile({ dev: true, sourceNames: { components: true, bindings: true, locations: false } }) + ); + }); + + test("a location rides only with its label", () => { + const components = compile({ dev: false, sourceNames: { components: true, locations: true } }); + expect(components).toContain(label); + expect(components).not.toContain("name:"); + expect(components).not.toContain("loc:"); + const bindings = compile({ dev: false, sourceNames: { bindings: true, locations: true } }); + expect(bindings).toContain(binding); + expect(bindings).not.toContain('"Home"'); + }); + + test("no `filename` → no location, even with the kind on", () => { + const out = compile({ dev: true, sourceNames: true }, null); + expect(out).toContain('_$createComponent(Home, {}, "Home")'); + expect(out).toContain('name: "div.class"'); + expect(out).not.toContain("loc:"); + }); + + test("the path is relative to `root`, posix, with `..` outside it", () => { + const abs = require("path").resolve("src/App.jsx"); + expect(compile({ dev: true }, abs)).toContain('"src/App.jsx:1:33"'); + expect(compile({ dev: true, root: "src" }, abs)).toContain('"App.jsx:1:33"'); + expect(compile({ dev: true, root: "test" }, abs)).toContain('"../src/App.jsx:1:33"'); + }); +}); From af9a51294c7eb9c3a59813db014683ee206b57fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:10:49 +0000 Subject: [PATCH 3/6] feat(compiler): sourceNames.locations carries file:line:col `sourceNames` gains a `locations` kind and `transform()` a `root` option. Where a name is emitted, its source position follows as `::` (path relative to `root`, default cwd, posix; line and column 1-based): a fourth `createComponent` argument at the opening element (DOM and SSR), `loc` after `name` in a binding's options (the attribute's start, a hole's `{`; a merged effect points at its first binding, a property split out of `style={{}}`/`class={{}}` at that attribute), and one more positional string after the tag for `spread`. Nothing without a `filename`. `locations` follows `dev` like the other kinds. `transformSourceNames` gains `locations` (default true) and `root`, and writes `loc` right after `name` in the options literal it writes or completes, keeping an explicit `name` and never overriding an explicit `loc`. `relative_id` and the refresh pass's `line_column` move to `shared::location`, which the no-`node` build can reach. The parity harness now gives Babel the same `.jsx` filename oxc gets, so the location strings can be compared once both compilers print them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ka6h98478umeFadcRV1KLY --- .changeset/compiler-source-names-locations.md | 7 + packages/compiler/README.md | 5 +- .../__tests__/cross-mode-parity.test.js | 2 +- .../dom-source-names-fixtures.test.js | 106 +++++++++++- .../walkValidation/output.js | 20 ++- .../dom-source-names/bindings/output.js | 73 ++++++-- .../dom-source-names/components/output.js | 27 +-- .../dom-source-names/locations/code.js | 27 +++ .../dom-source-names/locations/options.json | 3 + .../dom-source-names/locations/output.js | 76 +++++++++ .../fixtures/dom-source-names/ssr/output.js | 16 +- .../compiler/__tests__/option-matrix.test.js | 2 +- .../compiler/__tests__/parity-probes.test.js | 2 +- packages/compiler/__tests__/parity/harness.js | 9 +- .../__tests__/reported-parity.test.js | 2 +- .../compiler/__tests__/source-names.test.js | 82 ++++++++- packages/compiler/__tests__/transform.test.js | 11 +- packages/compiler/index.js | 20 ++- packages/compiler/src/compiler.rs | 22 +++ packages/compiler/src/config.rs | 15 +- packages/compiler/src/directives/mod.rs | 47 +---- packages/compiler/src/dom/attrs.rs | 2 + packages/compiler/src/dom/children.rs | 4 +- packages/compiler/src/dom/dynamics.rs | 35 ++-- packages/compiler/src/dom/element.rs | 15 ++ packages/compiler/src/dom/spread.rs | 17 +- packages/compiler/src/dom/template.rs | 32 +++- packages/compiler/src/node_adapter.rs | 2 + packages/compiler/src/refresh/transform.rs | 57 +------ packages/compiler/src/shared/attr_plan.rs | 15 ++ packages/compiler/src/shared/component.rs | 15 ++ packages/compiler/src/shared/location.rs | 146 ++++++++++++++++ packages/compiler/src/shared/mod.rs | 1 + packages/compiler/src/source_names.rs | 161 ++++++++++++++---- packages/compiler/src/ssr/transform.rs | 28 ++- packages/compiler/src/universal/transform.rs | 1 + packages/compiler/types.d.ts | 37 +++- 37 files changed, 901 insertions(+), 241 deletions(-) create mode 100644 .changeset/compiler-source-names-locations.md create mode 100644 packages/compiler/__tests__/fixtures/dom-source-names/locations/code.js create mode 100644 packages/compiler/__tests__/fixtures/dom-source-names/locations/options.json create mode 100644 packages/compiler/__tests__/fixtures/dom-source-names/locations/output.js create mode 100644 packages/compiler/src/shared/location.rs diff --git a/.changeset/compiler-source-names-locations.md b/.changeset/compiler-source-names-locations.md new file mode 100644 index 0000000000..434be64e78 --- /dev/null +++ b/.changeset/compiler-source-names-locations.md @@ -0,0 +1,7 @@ +--- +"@solidjs/compiler": patch +--- + +`sourceNames` gains a `locations` kind and `transform()` a `root` option: component and binding labels carry a `file:line:col` for diagnostics, printed the same as `@solidjs/babel-plugin`'s. `createComponent(Home, props, "Home", "src/App.tsx:12:5")` points at the `::` with the path relative to `root` (posix separators, `..` when the file is outside the root) and the column 1-based (the stack-trace and editor convention, so `http://localhost:5173/src/App.tsx:18:3` links to the right place): `createComponent(Home, props, "Home", "src/App.tsx:12:5")` — the start of the opening element — and `loc` right after `name` in a binding's options, `effect(…, { name: "div.class", loc: "src/App.tsx:14:7" })` — the attribute's start, a hole's `{`, a spread's `{...props}` (a spread's location rides as one more positional string after the tag). Nothing is emitted without a `filename`. The production runtimes ignore the names. These are the JSX-level kinds only: naming the primitives themselves is the separate `transformSourceNames` pass below, which the build tool runs on every module independently of the JSX compiler - `sourceMap` - `contextToCustomElements` (default `true`) - `delegateEvents` @@ -162,7 +163,7 @@ The runtime module defaults to `@solidjs/web/server-functions`. Function IDs are ### Source names for primitives -`transformSourceNames(code, { filename?, sourceMap? })` is the pass behind `@solidjs/vite-plugin`'s `sourceNames.primitives` option: it names reactive primitives after the identifier they are declared as, so the dev and observe runtimes label graph nodes `count` / `doubled` / `todos.title` instead of `signal` / `computed` / `store.title`. It is plain JavaScript in and out (JSX passes through untouched), which is why it is its own pass rather than a `transform()` option — primitives live in `.ts`/`.js` modules as much as in components. This standalone pass is the single owner of primitive naming: the build tool runs it on every module — `.ts`, `.js`, and JSX files alike — independently of which JSX compiler (this one or `@solidjs/babel-plugin`) handles the file's JSX, so there is no Babel counterpart and `transform()`'s `sourceNames` covers only the JSX-level kinds. `@solidjs/vite-plugin` (solid-vite-plugin #371, `solid: { sourceNames }`) runs it ahead of the JSX transform for the dev and `observe` postures. +`transformSourceNames(code, { filename?, locations?, root?, sourceMap? })` is the pass behind `@solidjs/vite-plugin`'s `sourceNames.primitives` option: it names reactive primitives after the identifier they are declared as, so the dev and observe runtimes label graph nodes `count` / `doubled` / `todos.title` instead of `signal` / `computed` / `store.title`. With `locations` (default `true` — the pass only runs for the dev and observe postures) and a `filename`, the options literal also carries the call's position right after the name, `createSignal(0, { name: "count", loc: "src/store.ts:3:22" })` (`filename` relative to `root`, column 1-based); an explicit `name` is kept and `loc` still added, an explicit `loc` is never overridden. It is plain JavaScript in and out (JSX passes through untouched), which is why it is its own pass rather than a `transform()` option — primitives live in `.ts`/`.js` modules as much as in components. This standalone pass is the single owner of primitive naming: the build tool runs it on every module — `.ts`, `.js`, and JSX files alike — independently of which JSX compiler (this one or `@solidjs/babel-plugin`) handles the file's JSX, so there is no Babel counterpart and `transform()`'s `sourceNames` covers only the JSX-level kinds. `@solidjs/vite-plugin` (solid-vite-plugin #371, `solid: { sourceNames }`) runs it ahead of the JSX transform for the dev and `observe` postures. ```js const [count, setCount] = createSignal(0); // createSignal(0, { name: "count" }) diff --git a/packages/compiler/__tests__/cross-mode-parity.test.js b/packages/compiler/__tests__/cross-mode-parity.test.js index f1901c05c3..47480f939f 100644 --- a/packages/compiler/__tests__/cross-mode-parity.test.js +++ b/packages/compiler/__tests__/cross-mode-parity.test.js @@ -70,7 +70,7 @@ function crossDiff(mode, entry) { const { options } = modes[mode]; let babelRaw, babelError; try { - babelRaw = compileBabel(entry.source, options); + babelRaw = compileBabel(entry.source, options, `${entry.id}.jsx`); } catch (err) { babelError = err; } diff --git a/packages/compiler/__tests__/dom-source-names-fixtures.test.js b/packages/compiler/__tests__/dom-source-names-fixtures.test.js index 5bb09500ac..c3c58adad2 100644 --- a/packages/compiler/__tests__/dom-source-names-fixtures.test.js +++ b/packages/compiler/__tests__/dom-source-names-fixtures.test.js @@ -11,6 +11,7 @@ const oxcFixtures = path.resolve(__dirname, "fixtures/dom-source-names"); const fixtureParity = { bindings: "subset", components: "subset", + locations: "subset", ssr: "subset" }; @@ -22,21 +23,38 @@ const suiteOptions = { contextToCustomElements: true }; +// A fixture's source is the Babel plugin's; a copy next to the output +// (`/code.js`, `/options.json`) stands in until the Babel +// fixture lands here. +function fixtureFile(fixture, name) { + const babelFile = path.join(babelFixtures, fixture, name); + return fs.existsSync(babelFile) ? babelFile : path.join(oxcFixtures, fixture, name); +} + function readFixture(name) { - return fs.readFileSync(path.join(babelFixtures, name, "code.js"), "utf8"); + return fs.readFileSync(fixtureFile(name, "code.js"), "utf8"); } // Mirrors the Babel fixture runner's per-fixture `options.json` layering. function fixtureOptions(fixture) { - const file = path.join(babelFixtures, fixture, "options.json"); + const file = fixtureFile(fixture, "options.json"); return fs.existsSync(file) ? { ...suiteOptions, ...JSON.parse(fs.readFileSync(file, "utf8")) } : suiteOptions; } +// A fixture that sets `root` (the `locations` one) is named the way the +// Babel runner names it — `//code.` — so the location +// strings read `locations/code.jsx:8:3`, the Babel snapshot's path up to the +// extension (Babel parses JSX in `.js`; here the extension picks the parser). +function fixtureFilename(fixture, options) { + return options.root ? `${options.root}/${fixture}/code.jsx` : `${fixture}.jsx`; +} + function transformFixture(code, fixture) { + const options = fixtureOptions(fixture); return ( - transform(code, { filename: `${fixture}.jsx`, ...fixtureOptions(fixture) }).code.trimEnd() + + transform(code, { filename: fixtureFilename(fixture, options), ...options }).code.trimEnd() + "\n" ); } @@ -56,12 +74,13 @@ function writeOutputFixture(fixture, output) { describe("AST-native Babel DOM sourceNames fixture reuse", () => { it("classifies supported Babel DOM sourceNames fixtures", () => { - const actual = fs - .readdirSync(babelFixtures, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .filter(entry => fs.existsSync(path.join(babelFixtures, entry.name, "code.js"))) - .map(entry => entry.name) - .sort(); + const sources = dir => + fs + .readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .filter(entry => fs.existsSync(path.join(dir, entry.name, "code.js"))) + .map(entry => entry.name); + const actual = [...new Set([...sources(babelFixtures), ...sources(oxcFixtures)])].sort(); expect(Object.keys(fixtureParity).sort()).toEqual(actual); }); @@ -98,3 +117,72 @@ describe("AST-native Babel DOM sourceNames fixture reuse", () => { expect(code).toContain("Child({"); }); }); + +// `sourceNames.locations` follows `dev` like the other kinds; a location is +// only emitted beside a label, and never without a `filename`. Same source, +// same expectations as the Babel plugin's `sourceNames locations defaults`. +describe("sourceNames locations defaults", () => { + const code = "const view =
;"; + const label = '_$createComponent(Home, {}, "Home", "input.jsx:1:33")'; + const binding = 'loc: "input.jsx:1:19"'; + const compile = (opts, filename = "input.jsx") => + transform(code, { moduleName: "r-dom", ...(filename ? { filename } : {}), ...opts }).code; + + it("unset follows dev: on in dev, off otherwise", () => { + const dev = compile({ dev: true }); + expect(dev).toContain(label); + expect(dev).toContain(binding); + const prod = compile({ dev: false }); + expect(prod).not.toContain("input.jsx"); + expect(prod).not.toContain("loc:"); + expect(prod).toBe(compile({})); + }); + + it("`{ locations: false }` in dev drops only the locations", () => { + const named = compile({ dev: true, sourceNames: { locations: false } }); + expect(named).toContain('_$createComponent(Home, {}, "Home")'); + expect(named).toContain('name: "div.class"'); + expect(named).not.toContain("input.jsx"); + expect(named).not.toContain("loc:"); + expect(named).toBe( + compile({ dev: true, sourceNames: { components: true, bindings: true, locations: false } }) + ); + }); + + it("a location rides only with its label", () => { + const components = compile({ dev: false, sourceNames: { components: true, locations: true } }); + expect(components).toContain(label); + expect(components).not.toContain("name:"); + expect(components).not.toContain("loc:"); + const bindings = compile({ dev: false, sourceNames: { bindings: true, locations: true } }); + expect(bindings).toContain(binding); + expect(bindings).not.toContain('"Home"'); + }); + + it("no `filename` → no location, even with the kind on", () => { + const out = compile({ dev: true, sourceNames: true }, null); + expect(out).toContain('_$createComponent(Home, {}, "Home")'); + expect(out).toContain('name: "div.class"'); + expect(out).not.toContain("loc:"); + }); + + it("the path is relative to `root`, posix, with `..` outside it", () => { + const abs = path.resolve("src/App.jsx"); + expect(compile({ dev: true }, abs)).toContain('"src/App.jsx:1:33"'); + expect(compile({ dev: true, root: "src" }, abs)).toContain('"App.jsx:1:33"'); + expect(compile({ dev: true, root: path.resolve("src") }, abs)).toContain('"App.jsx:1:33"'); + expect(compile({ dev: true, root: "test" }, abs)).toContain('"../src/App.jsx:1:33"'); + }); + + it("SSR output carries the location as the fourth argument too", () => { + const out = compile({ dev: true, generate: "ssr" }); + expect(out).toContain(label); + }); + + it("validates `root` and the `locations` kind", () => { + expect(() => compile({ root: 1 })).toThrow("`root` option must be a string"); + expect(() => compile({ sourceNames: { locations: "yes" } })).toThrow( + "`sourceNames.locations` must be boolean" + ); + }); +}); diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js index ff78c2dd31..2b907e594b 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js @@ -13,26 +13,38 @@ var _el$ = _$getNextElement(_tmpl$); var _el$2 = _$getFirstChild(_el$, "span"); _$insert(_el$2, _$scope(() => { return name(); -}), undefined, undefined, { name: "span.children" }); +}), undefined, undefined, { + name: "span.children", + loc: "walkValidation.jsx:5:11" +}); const singleChild = _el$; var _el$3 = _$getNextElement(_tmpl$2); var _el$4 = _$getFirstChild(_el$3, "header"); var _el$5 = _$getNextSibling(_el$4, "main"); _$insert(_el$5, _$scope(() => { return name(); -}), undefined, undefined, { name: "main.children" }); +}), undefined, undefined, { + name: "main.children", + loc: "walkValidation.jsx:12:11" +}); const siblingElements = _el$3; var _el$6 = _$getNextElement(_tmpl$3); var _el$7 = _el$6.firstChild; var _el$8 = _$getNextSibling(_el$7, "b"); _$insert(_el$8, _$scope(() => { return name(); -}), undefined, undefined, { name: "b.children" }); +}), undefined, undefined, { + name: "b.children", + loc: "walkValidation.jsx:19:14" +}); const mixedTextAndElements = _el$6; var _el$9 = _$getNextElement(_tmpl$4); var _el$10 = _$getFirstChild(_el$9, "ul"); var _el$11 = _$getFirstChild(_el$10, "li"); _$insert(_el$11, _$scope(() => { return name(); -}), undefined, undefined, { name: "li.children" }); +}), undefined, undefined, { + name: "li.children", + loc: "walkValidation.jsx:26:11" +}); const nestedWalk = _el$9; diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/bindings/output.js b/packages/compiler/__tests__/fixtures/dom-source-names/bindings/output.js index cce665bc3e..7e815e9b94 100644 --- a/packages/compiler/__tests__/fixtures/dom-source-names/bindings/output.js +++ b/packages/compiler/__tests__/fixtures/dom-source-names/bindings/output.js @@ -22,18 +22,27 @@ var _el$ = _tmpl$(); var _el$2 = _el$.firstChild; _$effect(() => label(), (_v$) => { _el$2.data = _v$; -}, { name: "span.textContent" }); +}, { + name: "span.textContent", + loc: "bindings.jsx:2:22" +}); // One binding → its own effect, named `.`. const single = _el$; var _el$3 = _tmpl$2(); _$effect(() => text(), (_v$) => { _el$3.value = _v$ ?? ""; -}, { name: "input.value" }); +}, { + name: "input.value", + loc: "bindings.jsx:3:21" +}); const attr = _el$3; var _el$4 = _tmpl$3(); _$effect(() => _$readShallow(active() ? "on" : "off"), (_v$, _$p) => { _$className(_el$4, _v$, _$p); -}, { name: "div.class" }); +}, { + name: "div.class", + loc: "bindings.jsx:4:18" +}); const cls = _el$4; var _el$5 = _tmpl$3(); _$effect(() => { @@ -44,12 +53,18 @@ _$effect(() => { }, ({ e, t }, _p$) => { e !== _p$?.e && _$setStyleProperty(_el$5, "color", e); t !== _p$?.t && _$setStyleProperty(_el$5, "font-size", t); -}, { name: "div.style:color, div.style:font-size" }); +}, { + name: "div.style:color, div.style:font-size", + loc: "bindings.jsx:5:20" +}); const style = _el$5; var _el$6 = _tmpl$3(); _$effect(() => ({ active: active() }), (_v$) => { _$setAttribute(_el$6, "classList", _v$); -}, { name: "div.classList" }); +}, { + name: "div.classList", + loc: "bindings.jsx:6:24" +}); const classList = _el$6; var _el$7 = _tmpl$3(); _$effect(() => { @@ -60,7 +75,10 @@ _$effect(() => { }, ({ e, t }, _p$) => { e !== _p$?.e && _$setAttribute(_el$7, "class:selected", e); t !== _p$?.t && _$setAttribute(_el$7, "style:width", t); -}, { name: "div.class:selected, div.style:width" }); +}, { + name: "div.class:selected, div.style:width", + loc: "bindings.jsx:7:17" +}); const ns = _el$7; var _el$8 = _tmpl$4(); var _el$9 = _el$8.firstChild; @@ -77,11 +95,17 @@ _$effect(() => { t !== _p$?.t && _$setAttribute(_el$8, "title", t); a !== _p$?.a && _$setAttribute(_el$8, "disabled", a); (!_p$ || o !== _p$.o) && (_el$10.data = o); -}, { name: "button.class, button.title, button.disabled, span.textContent" }); +}, { + name: "button.class, button.title, button.disabled, span.textContent", + loc: "bindings.jsx:11:11" +}); // Several bindings in one template → one merged effect listing all of them. const merged = _el$8; var _el$11 = _tmpl$3(); -_$insert(_el$11, count, undefined, undefined, { name: "div.children" }); +_$insert(_el$11, count, undefined, undefined, { + name: "div.children", + loc: "bindings.jsx:17:19" +}); // Holes → insert, named for the parent it fills. const hole = _el$11; var _el$12 = _tmpl$5(); @@ -90,21 +114,33 @@ var _el$14 = _el$13.nextSibling; var _el$15 = _el$14.nextSibling; var _el$16 = _el$15.nextSibling; var _el$17 = _el$16.nextSibling; -_$insert(_el$12, name, _el$14, undefined, { name: "div.children" }); -_$insert(_el$16, greeting, undefined, undefined, { name: "p.children" }); -_$insert(_el$12, list, _el$17, undefined, { name: "div.children" }); +_$insert(_el$12, name, _el$14, undefined, { + name: "div.children", + loc: "bindings.jsx:20:11" +}); +_$insert(_el$16, greeting, undefined, undefined, { + name: "p.children", + loc: "bindings.jsx:20:23" +}); +_$insert(_el$12, list, _el$17, undefined, { + name: "div.children", + loc: "bindings.jsx:21:5" +}); const holes = _el$12; const staticChild = _tmpl$6(); var _el$19 = _tmpl$3(); -_$insert(_el$19, _$createComponent(Child, {}, "Child")); +_$insert(_el$19, _$createComponent(Child, {}, "Child", "bindings.jsx:27:5")); const componentChild = _el$19; var _el$20 = _tmpl$3(); -_$spread(_el$20, props, false, undefined, "div"); +_$spread(_el$20, props, false, undefined, "div", "bindings.jsx:32:21"); // Spreads → the tag rides as spread's trailing argument. const spread = _el$20; var _el$21 = _tmpl$7(); -_$spread(_el$21, props, true, undefined, "section"); -_$insert(_el$21, children, undefined, undefined, { name: "section.children" }); +_$spread(_el$21, props, true, undefined, "section", "bindings.jsx:33:37"); +_$insert(_el$21, children, undefined, undefined, { + name: "section.children", + loc: "bindings.jsx:33:48" +}); const spreadWithChildren = _el$21; var _el$22 = _tmpl$8(); _$spread(_el$22, [ @@ -113,7 +149,7 @@ _$spread(_el$22, [ } }, rest, { title: "static" } -], false, undefined, "a"); +], false, undefined, "a", "bindings.jsx:34:38"); const spreadMixed = _el$22; var _el$23 = _tmpl$9(); var _o$ = _$getOwner(); @@ -126,6 +162,9 @@ _$effect(() => { e !== _p$?.e && _$setAttribute(_el$23, "href", e); t !== _p$?.t && _$setAttribute(_el$23, "target", t); _$claimElement(_el$23, _o$); -}, { name: "a.href, a.target" }); +}, { + name: "a.href, a.target", + loc: "bindings.jsx:38:6" +}); // A claim target with bindings → its claim is the tail of the named effect. const link = _el$23; diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/components/output.js b/packages/compiler/__tests__/fixtures/dom-source-names/components/output.js index 8acd109510..964d3a7222 100644 --- a/packages/compiler/__tests__/fixtures/dom-source-names/components/output.js +++ b/packages/compiler/__tests__/fixtures/dom-source-names/components/output.js @@ -7,7 +7,7 @@ import { Show as _$Show } from "r-dom"; var _tmpl$ = /* @__PURE__ */ _$template(``); var _tmpl$2 = /* @__PURE__ */ _$template(`
`); import { Child, Ui, Row } from "./components"; -const Component = () => _$createComponent(Child, { name: "John" }, "Child"); +const Component = () => _$createComponent(Child, { name: "John" }, "Child", "components.jsx:3:25"); var _el$ = _tmpl$2(); var _el$3 = _el$.firstChild; var _el$4 = _el$3.nextSibling; @@ -18,36 +18,39 @@ var _el$8 = _el$7.nextSibling; var _el$9 = _el$8.nextSibling; _$insert(_el$, _$createComponent(Child, _$mergeProps({ name: "Jane" }, props, { get children() { var _el$2 = _tmpl$(); - _$insert(_el$2, name, undefined, undefined, { name: "span.children" }); + _$insert(_el$2, name, undefined, undefined, { + name: "span.children", + loc: "components.jsx:8:13" + }); return _el$2; -} }), "Child"), _el$3); -_$insert(_el$, _$createComponent(Ui.Button, { variant: "primary" }, "Ui.Button"), _el$4); +} }), "Child", "components.jsx:7:5"), _el$3); +_$insert(_el$, _$createComponent(Ui.Button, { variant: "primary" }, "Ui.Button", "components.jsx:10:5"), _el$4); _$insert(_el$, _$createComponent(Ui.Layout.Grid, { cols: 2, children: "text" -}, "Ui.Layout.Grid"), _el$5); +}, "Ui.Layout.Grid", "components.jsx:11:5"), _el$5); _$insert(_el$, _$createComponent(_$For, { get each() { return list(); }, - children: (item) => _$createComponent(Row, { item }, "Row") -}, "For"), _el$6); + children: (item) => _$createComponent(Row, { item }, "Row", "components.jsx:12:33") +}, "For", "components.jsx:12:5"), _el$6); _$insert(_el$, _$createComponent(_$Show, { get when() { return visible(); }, get children() { - return _$createComponent(Child, {}, "Child"); + return _$createComponent(Child, {}, "Child", "components.jsx:14:7"); } -}, "Show"), _el$7); -_$insert(_el$, _$createComponent(_self$.Row, {}, "this.Row"), _el$8); -_$insert(_el$, _$createComponent(Comp, { children: () => _$createComponent(Child, {}, "Child") }, "Comp"), _el$9); +}, "Show", "components.jsx:13:5"), _el$7); +_$insert(_el$, _$createComponent(_self$.Row, {}, "this.Row", "components.jsx:16:5"), _el$8); +_$insert(_el$, _$createComponent(Comp, { children: () => _$createComponent(Child, {}, "Child", "components.jsx:17:18") }, "Comp", "components.jsx:17:5"), _el$9); const template = (() => { const _self$ = this; return _el$; })(); class Container { render() { - return _$createComponent(this.Row, {}, "this.Row"); + return _$createComponent(this.Row, {}, "this.Row", "components.jsx:23:12"); } } diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/locations/code.js b/packages/compiler/__tests__/fixtures/dom-source-names/locations/code.js new file mode 100644 index 0000000000..63fead893c --- /dev/null +++ b/packages/compiler/__tests__/fixtures/dom-source-names/locations/code.js @@ -0,0 +1,27 @@ +// `sourceNames.locations`: every label carries where its JSX is written, +// `file:line:col` relative to `root` (here the fixtures directory, resolved +// against the working directory), line and column 1-based. +import { Layout, Card, Row } from "./components"; + +// Nested components → each `createComponent` points at its own ` + + + + +); + +// An attribute binding → the attribute's start; a merged effect points at +// the first binding it lists. +const attribute =
; +const styleObject =
; + +// A hole → the `{` of the expression. +const hole =

Count: {count()}

; + +// A spread → the `{...` of the first spread attribute. +const spread =
; + +// A component inside a conditional → the conditional's memo keeps the label. +const conditional =
{visible() ? : null}
; diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/locations/options.json b/packages/compiler/__tests__/fixtures/dom-source-names/locations/options.json new file mode 100644 index 0000000000..87d887988f --- /dev/null +++ b/packages/compiler/__tests__/fixtures/dom-source-names/locations/options.json @@ -0,0 +1,3 @@ +{ + "root": "test/__dom_source_names_fixtures__" +} diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/locations/output.js b/packages/compiler/__tests__/fixtures/dom-source-names/locations/output.js new file mode 100644 index 0000000000..786889b28c --- /dev/null +++ b/packages/compiler/__tests__/fixtures/dom-source-names/locations/output.js @@ -0,0 +1,76 @@ +import { template as _$template } from "r-dom"; +import { insert as _$insert } from "r-dom"; +import { memo as _$memo } from "r-dom"; +import { createComponent as _$createComponent } from "r-dom"; +import { spread as _$spread } from "r-dom"; +import { setStyleProperty as _$setStyleProperty } from "r-dom"; +import { readShallow as _$readShallow } from "r-dom"; +import { className as _$className } from "r-dom"; +import { effect as _$effect } from "r-dom"; +import { setAttribute as _$setAttribute } from "r-dom"; +var _tmpl$ = /* @__PURE__ */ _$template(`
`); +var _tmpl$2 = /* @__PURE__ */ _$template(`

Count: `); +var _tmpl$3 = /* @__PURE__ */ _$template(`

`); +// `sourceNames.locations`: every label carries where its JSX is written, +// `file:line:col` relative to `root` (here the fixtures directory, resolved +// against the working directory), line and column 1-based. +import { Layout, Card, Row } from "./components"; +// Nested components → each `createComponent` points at its own ` { + return { + e: _$readShallow(cls()), + t: title() + }; +}, ({ e, t }, _p$) => { + _$className(_el$, e, _p$?.e); + t !== _p$?.t && _$setAttribute(_el$, "title", t); +}, { + name: "div.class, div.title", + loc: "locations/code.jsx:17:24" +}); +// An attribute binding → the attribute's start; a merged effect points at +// the first binding it lists. +const attribute = _el$; +var _el$2 = _tmpl$(); +_$effect(() => color(), (_v$) => { + _$setStyleProperty(_el$2, "color", _v$); +}, { + name: "div.style:color", + loc: "locations/code.jsx:18:26" +}); +const styleObject = _el$2; +var _el$3 = _tmpl$2(); +var _el$4 = _el$3.firstChild; +_$insert(_el$3, count, null, undefined, { + name: "p.children", + loc: "locations/code.jsx:21:24" +}); +// A hole → the `{` of the expression. +const hole = _el$3; +var _el$5 = _tmpl$3(); +_$spread(_el$5, props, false, undefined, "section", "locations/code.jsx:24:25"); +// A spread → the `{...` of the first spread attribute. +const spread = _el$5; +var _el$6 = _tmpl$(); +_$insert(_el$6, (() => { + var _c$ = _$memo(() => { + return !!visible(); + }); + return () => { + return _c$() ? _$createComponent(Card, { title: "On" }, "Card", "locations/code.jsx:27:39") : null; + }; +})(), undefined, undefined, { + name: "div.children", + loc: "locations/code.jsx:27:26" +}); +// A component inside a conditional → the conditional's memo keeps the label. +const conditional = _el$6; diff --git a/packages/compiler/__tests__/fixtures/dom-source-names/ssr/output.js b/packages/compiler/__tests__/fixtures/dom-source-names/ssr/output.js index c05fcc2a32..85e807d060 100644 --- a/packages/compiler/__tests__/fixtures/dom-source-names/ssr/output.js +++ b/packages/compiler/__tests__/fixtures/dom-source-names/ssr/output.js @@ -17,33 +17,33 @@ var _tmpl$2 = [ "
" ]; import { Child, Ui, Row } from "./components"; -const Component = () => _$createComponent(Child, { name: "John" }, "Child"); +const Component = () => _$createComponent(Child, { name: "John" }, "Child", "ssr.jsx:3:25"); const template = (() => { var _v$2 = _$escape(_$createComponent(Child, _$mergeProps({ name: "Jane" }, props, { get children() { return _v$ = () => { return _$escape(name()); }, _$ssr(_tmpl$, _v$); - } }), "Child")), _v$3 = _$escape(_$createComponent(Ui.Button, { variant: "primary" }, "Ui.Button")), _v$4 = _$escape(_$createComponent(Ui.Layout.Grid, { + } }), "Child", "ssr.jsx:7:5")), _v$3 = _$escape(_$createComponent(Ui.Button, { variant: "primary" }, "Ui.Button", "ssr.jsx:10:5")), _v$4 = _$escape(_$createComponent(Ui.Layout.Grid, { cols: 2, children: "text" - }, "Ui.Layout.Grid")), _v$5 = _$escape(_$createComponent(_$For, { + }, "Ui.Layout.Grid", "ssr.jsx:11:5")), _v$5 = _$escape(_$createComponent(_$For, { get each() { return list(); }, - children: (item) => _$createComponent(Row, { item }, "Row") - }, "For")), _v$6 = _$escape(_$createComponent(_$Show, { + children: (item) => _$createComponent(Row, { item }, "Row", "ssr.jsx:12:33") + }, "For", "ssr.jsx:12:5")), _v$6 = _$escape(_$createComponent(_$Show, { get when() { return visible(); }, get children() { - return _$createComponent(Child, {}, "Child"); + return _$createComponent(Child, {}, "Child", "ssr.jsx:14:7"); } - }, "Show")), _v$7 = _$escape(_$createComponent(_self$.Row, {}, "this.Row")), _v$8 = _$escape(_$createComponent(Comp, { children: () => _$createComponent(Child, {}, "Child") }, "Comp")); + }, "Show", "ssr.jsx:13:5")), _v$7 = _$escape(_$createComponent(_self$.Row, {}, "this.Row", "ssr.jsx:16:5")), _v$8 = _$escape(_$createComponent(Comp, { children: () => _$createComponent(Child, {}, "Child", "ssr.jsx:17:18") }, "Comp", "ssr.jsx:17:5")); const _self$ = this; return _$ssr(_tmpl$2, _v$2, _v$3, _v$4, _v$5, _v$6, _v$7, _v$8); })(); class Container { render() { - return _$createComponent(this.Row, {}, "this.Row"); + return _$createComponent(this.Row, {}, "this.Row", "ssr.jsx:23:12"); } } diff --git a/packages/compiler/__tests__/option-matrix.test.js b/packages/compiler/__tests__/option-matrix.test.js index e5e82a9dff..84eeafe2e3 100644 --- a/packages/compiler/__tests__/option-matrix.test.js +++ b/packages/compiler/__tests__/option-matrix.test.js @@ -49,7 +49,7 @@ describe("option-matrix parity", () => { const source = readFixtureSource(modeName, fixture); let babelRaw, oxcRaw, babelErr, oxcErr; try { - babelRaw = compileBabel(source, options); + babelRaw = compileBabel(source, options, `${fixture}.jsx`); } catch (err) { babelErr = err.message.split("\n")[0]; } diff --git a/packages/compiler/__tests__/parity-probes.test.js b/packages/compiler/__tests__/parity-probes.test.js index 5fd816e71a..4f48d3d5fa 100644 --- a/packages/compiler/__tests__/parity-probes.test.js +++ b/packages/compiler/__tests__/parity-probes.test.js @@ -1320,7 +1320,7 @@ describe("Babel vs Oxc parity probes", () => { // nesting in dynamic mode); both compilers rejecting is parity too. let babelOut, babelError; try { - babelOut = normalize(compileBabel(source, options)); + babelOut = normalize(compileBabel(source, options, "probe.jsx")); } catch (err) { babelError = err; } diff --git a/packages/compiler/__tests__/parity/harness.js b/packages/compiler/__tests__/parity/harness.js index 956082a295..7937e6cd4e 100644 --- a/packages/compiler/__tests__/parity/harness.js +++ b/packages/compiler/__tests__/parity/harness.js @@ -524,10 +524,11 @@ function compareFixture(mode, fixture) { const source = readFixtureSource(mode, fixture); let babelRaw, oxcRaw; try { - // Only TSRX modes set `extension`; JSX modes keep Babel filename-free so - // established expectations (e.g. dev-mode location info) stay untouched. - const babelFilename = modes[mode].extension ? `${fixture}${extension}` : undefined; - babelRaw = compileBabel(source, options, babelFilename); + // Both compilers see the same filename: TSRX modes route on the + // extension, and `sourceNames.locations` (on under `dev`) prints the + // cwd-relative path into the output, so a filename on one side only + // would be a divergence of the harness, not the compilers. + babelRaw = compileBabel(source, options, `${fixture}${extension}`); } catch (err) { throw new Error(`babel-plugin failed on ${mode}/${fixture}: ${err.message}`); } diff --git a/packages/compiler/__tests__/reported-parity.test.js b/packages/compiler/__tests__/reported-parity.test.js index 757fc18cd6..d8c5795399 100644 --- a/packages/compiler/__tests__/reported-parity.test.js +++ b/packages/compiler/__tests__/reported-parity.test.js @@ -65,7 +65,7 @@ const view =