Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"express": "4.22.0",
"get-port": "7.2.0",
"js-yaml": "4.1.1",
"magic-string": "0.30.21",
"obug": "2.1.1",
"pg": "8.18.0",
"picocolors": "1.1.1",
Expand Down
102 changes: 102 additions & 0 deletions packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import path from "node:path";
import { Lang, parse, type SgNode } from "@ast-grep/napi";
import MagicString from "magic-string";
import type { Plugin } from "vite";

const JSX_ELEMENT_MATCHER = {
rule: {
any: [
{ kind: "jsx_opening_element" },
{ kind: "jsx_self_closing_element" },
],
},
};

/** Matches nested client dirs from ViteDevServer.findClientRoot() (not "."). */
const NESTED_CLIENT_DIRS = new Set(["client", "src", "app", "frontend"]);

function resolveProjectRoot(clientRoot: string): string {
const resolved = path.resolve(clientRoot);
if (NESTED_CLIENT_DIRS.has(path.basename(resolved))) {
return path.resolve(resolved, "..");
}
return resolved;
}

function cleanModuleId(id: string): string {
return id.split("?")[0].split("#")[0];
}

function shouldTransform(id: string): boolean {
if (id.includes("\0")) return false;
if (id.includes("node_modules")) return false;
return /\.[jt]sx$/.test(cleanModuleId(id));
}

function isNativeJsxTag(name: SgNode): boolean {
const kind = name.kind();
if (kind === "member_expression") return false;
if (kind === "jsx_namespace_name") return false;
if (kind === "identifier") {
const tagName = name.text();
if (!tagName) return false;
return /^[a-z]/.test(tagName);
}
return false;
}

function hasDataSourceAttribute(node: SgNode): boolean {
for (const attr of node.fieldChildren("attribute")) {
if (!attr.is("jsx_attribute")) continue;
for (const child of attr.children()) {
if (child.is("property_identifier") && child.text() === "data-source") {
return true;
}
}
}
return false;
}

/**
* Injects `data-source="<file>:<line>:<col>"` on native JSX elements so editors
* can map DOM nodes back to source locations.
*/
export function reactSourceLocPlugin(): Plugin {
let projectRoot: string;

return {
name: "react-source-loc",
enforce: "pre",
apply: "serve",

configResolved(config) {
projectRoot = resolveProjectRoot(config.root);
},

transform(code, id) {
if (!shouldTransform(id)) return;

const cleanId = cleanModuleId(id);
const root = parse(Lang.Tsx, code).root();
const s = new MagicString(code);
const relPath = path.relative(projectRoot, cleanId);

for (const node of root.findAll(JSX_ELEMENT_MATCHER)) {
const name = node.field("name");
if (!name || !isNativeJsxTag(name)) continue;
if (hasDataSourceAttribute(node)) continue;

const nodeRange = node.range();
const value = `${relPath}:${nodeRange.start.line + 1}:${nodeRange.start.column}`;
s.appendLeft(name.range().end.index, ` data-source="${value}"`);
}

if (!s.hasChanged()) return;

return {
code: s.toString(),
map: s.generateMap({ hires: true }),
};
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ResolvedConfig } from "vite";
import { describe, expect, it } from "vitest";
import { reactSourceLocPlugin } from "../react-source-loc-vite-plugin";

const clientRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"client",
);
const moduleId = path.join(clientRoot, "src", "Example.tsx");

interface TestableHooks {
configResolved?: (config: ResolvedConfig) => void | Promise<void>;
transform?: (
code: string,
id: string,
) =>
| { code: string }
| string
| null
| undefined
| Promise<{ code: string } | string | null | undefined>;
}

async function transformSource(
code: string,
root: string = clientRoot,
id: string = moduleId,
): Promise<string> {
const { configResolved, transform } =
reactSourceLocPlugin() as unknown as TestableHooks;
const config = { root } as ResolvedConfig;

await configResolved?.(config);

const result = await transform?.(code, id);
if (!result) return code;
return typeof result === "string" ? result : result.code;
}

describe("reactSourceLocPlugin", () => {
it("injects data-source on native opening and self-closing tags", async () => {
const code = `export function App() {
return (
<motion.div>
<div className="a">
<span />
</div>
</motion.div>
);
}
`;
const output = await transformSource(code);
expect(output).toContain('data-source="client/src/Example.tsx:');
expect(output).toMatch(/<motion\.div>/);
expect(output).toMatch(/<div data-source="[^"]+" className="a">/);
expect(output).toMatch(/<span data-source="[^"]+" \/>/);
expect(output).not.toContain("motion.div data-source");
});

it("skips components, fragments, namespaced tags, and existing data-source", async () => {
const code = `export function App() {
return (
<>
<Foo />
<Foo.Bar />
<svg:circle />
<motion.div data-source="manual" />
</>
);
}
`;
const output = await transformSource(code);
expect(output).not.toMatch(/<Foo data-source=/);
expect(output).not.toMatch(/<Foo\.Bar data-source=/);
expect(output).not.toMatch(/<svg:circle data-source=/);
expect(output).toContain('<motion.div data-source="manual"');
expect(output).not.toMatch(/data-source="[^"]+" data-source=/);
});

it("resolves paths from app root when vite root is not a nested client dir", async () => {
const appRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"flat-app",
);
const flatModuleId = path.join(appRoot, "src", "Page.tsx");
const code = `export const Page = () => <div className="x" />;`;

const output = await transformSource(code, appRoot, flatModuleId);

expect(output).toMatch(/<div data-source="src\/Page\.tsx:/);
expect(output).not.toMatch(/data-source="[^"]*\.\./);
});
});
2 changes: 2 additions & 0 deletions packages/appkit/src/plugins/server/vite-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { appKitServingTypesPlugin } from "../../type-generator/serving/vite-plug
import { appKitTypesPlugin } from "../../type-generator/vite-plugin";
import { mergeConfigDedup } from "../../utils";
import { BaseServer } from "./base-server";
import { reactSourceLocPlugin } from "./react-source-loc-vite-plugin";
import type { PluginClientConfigs, PluginEndpoints } from "./utils";

const logger = createLogger("server:vite");
Expand Down Expand Up @@ -81,6 +82,7 @@ export class ViteDevServer extends BaseServer {
},
plugins: [
react.default(),
reactSourceLocPlugin(),
appKitTypesPlugin(),
appKitServingTypesPlugin(),
],
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 2 additions & 4 deletions template/client/vite.config.ts
Comment thread
MarioCadenas marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ import path from 'node:path';
// https://vite.dev/config/
export default defineConfig({
root: __dirname,
plugins: [
react(),
tailwindcss(),
],
plugins: [react(), tailwindcss()],
server: {
middlewareMode: true,
},
build: {
outDir: path.resolve(__dirname, './dist'),
emptyOutDir: true,
sourcemap: true,
Comment thread
MarioCadenas marked this conversation as resolved.
Outdated
},
optimizeDeps: {
include: ['react', 'react-dom', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'recharts'],
Expand Down
6 changes: 3 additions & 3 deletions template/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading