Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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,
},
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