Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/safe-proxy-entrypoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@tailor-platform/app-shell-vite-plugin": minor
---

Add `entrypoint` option to `appShellRoutes()` plugin. When specified, only imports from the entrypoint file are intercepted and replaced with the pages-injected AppShell, eliminating circular module dependencies entirely. It is recommended to set this option to avoid potential TDZ errors caused by circular imports in page components.

```ts
appShellRoutes({
entrypoint: "src/App.tsx",
});
```
1 change: 1 addition & 0 deletions examples/vite-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
react(),
tailwindcss(),
appShellRoutes({
entrypoint: "src/App.tsx",
generateTypedRoutes: true,
}),
],
Expand Down
2 changes: 2 additions & 0 deletions packages/vite-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ResolvedOptions = {
logLevel: LogLevel;
generateTypedRoutes: boolean;
typedRoutesOutput: string;
entrypoint: string | undefined;
};

/**
Expand Down Expand Up @@ -54,6 +55,7 @@ function createPluginContext(userOptions: AppShellRoutesPluginOptions): PluginCo
logLevel: userOptions.logLevel ?? "info",
generateTypedRoutes,
typedRoutesOutput,
entrypoint: userOptions.entrypoint,
};

const state: PluginState = {
Expand Down
96 changes: 96 additions & 0 deletions packages/vite-plugin/src/plugins/auto-inject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import { createAutoInjectPlugin } from "./auto-inject";
import { APP_SHELL_PACKAGE, VIRTUAL_PROXY_ID, VIRTUAL_MODULE_ID } from "../constants";
import type { PluginContext, PluginState } from "../plugin";
import { createLogger } from "../utils/logger";

function createTestPlugin(entrypoint?: string) {
const state: PluginState = { resolvedPagesDir: "", cachedPages: null };
const ctx: PluginContext = {
options: {
pagesDir: "src/pages",
logLevel: "off",
generateTypedRoutes: false,
typedRoutesOutput: "",
entrypoint,
},
state,
log: createLogger("off"),
};

const plugin = createAutoInjectPlugin(ctx);

// Simulate configResolved to resolve entrypoint path
if (plugin.configResolved) {
const hook = plugin.configResolved as (config: { root: string }) => void;
hook({ root: "/project" });
}

const resolveId = plugin.resolveId as (id: string, importer: string | undefined) => string | null;
const load = plugin.load as (id: string) => string | null;

return { resolveId, load };
}

describe("auto-inject plugin", () => {
describe("resolveId with entrypoint", () => {
it("intercepts app-shell imports from the entrypoint", () => {
const { resolveId } = createTestPlugin("src/App.tsx");
const result = resolveId(APP_SHELL_PACKAGE, "/project/src/App.tsx");
expect(result).toBe(VIRTUAL_PROXY_ID);
});

it("does NOT intercept imports from non-entrypoint files", () => {
const { resolveId } = createTestPlugin("src/App.tsx");
const result = resolveId(APP_SHELL_PACKAGE, "/project/src/pages/dashboard/page.tsx");
expect(result).toBeNull();
});

it("does NOT intercept imports from virtual modules", () => {
const { resolveId } = createTestPlugin("src/App.tsx");
const result = resolveId(APP_SHELL_PACKAGE, "\0virtual:app-shell-proxy");
expect(result).toBeNull();
});

it("ignores unrelated imports", () => {
const { resolveId } = createTestPlugin("src/App.tsx");
const result = resolveId("react", "/project/src/App.tsx");
expect(result).toBeNull();
});
});

describe("resolveId without entrypoint (legacy)", () => {
it("intercepts app-shell imports from any user file", () => {
const { resolveId } = createTestPlugin();
const result = resolveId(APP_SHELL_PACKAGE, "/project/src/App.tsx");
expect(result).toBe(VIRTUAL_PROXY_ID);
});

it("intercepts app-shell imports from page components", () => {
const { resolveId } = createTestPlugin();
const result = resolveId(APP_SHELL_PACKAGE, "/project/src/pages/dashboard/page.tsx");
expect(result).toBe(VIRTUAL_PROXY_ID);
});

it("does NOT intercept imports from virtual modules", () => {
const { resolveId } = createTestPlugin();
const result = resolveId(APP_SHELL_PACKAGE, "\0virtual:app-shell-proxy");
expect(result).toBeNull();
});
});

describe("load", () => {
it("proxy module re-exports real package and overrides AppShell with pages", () => {
const { load } = createTestPlugin();
const code = load(VIRTUAL_PROXY_ID);
expect(code).toContain(`import { pages } from "${VIRTUAL_MODULE_ID}"`);
expect(code).toContain(`export * from "${APP_SHELL_PACKAGE}"`);
expect(code).toContain("_OriginalAppShell.WithPages(pages)");
});

it("returns null for unknown module ids", () => {
const { load } = createTestPlugin();
expect(load("unknown")).toBeNull();
});
});
});
35 changes: 31 additions & 4 deletions packages/vite-plugin/src/plugins/auto-inject.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,51 @@
import path from "node:path";
import type { Plugin } from "vite";
import { normalizePath } from "vite";
import type { PluginContext } from "../plugin";
import { VIRTUAL_MODULE_ID, APP_SHELL_PACKAGE, VIRTUAL_PROXY_ID } from "../constants";

/**
* Create the auto-inject plugin.
*
* This plugin intercepts @tailor-platform/app-shell imports
* and wraps AppShell with pre-configured pages using AppShell.WithPages().
* This plugin intercepts @tailor-platform/app-shell imports and replaces
* AppShell with a version that has pages pre-configured via AppShell.WithPages().
*
* ## Entrypoint mode (recommended)
*
* When `options.entrypoint` is set, only imports from that file are intercepted.
* All other files import directly from the real package, so there is no circular
* dependency in the module graph.
*
* ## Legacy mode (entrypoint not set)
*
* All user-code imports are intercepted. This creates a circular dependency
* (proxy → pages → page components → proxy) which works in practice because
* indirect re-exports resolve to the already-evaluated real package. However,
* page components must NOT import `AppShell` in this mode, as it would hit a
* TDZ error.
*/
export function createAutoInjectPlugin(ctx: PluginContext): Plugin {
const { log } = ctx;
let resolvedEntrypoint: string | undefined;

return {
name: "app-shell-auto-pages-inject",
enforce: "pre",

configResolved(config) {
if (ctx.options.entrypoint) {
resolvedEntrypoint = normalizePath(path.resolve(config.root, ctx.options.entrypoint));
log.debug(`Entrypoint resolved to ${resolvedEntrypoint}`);
}
},

resolveId(id, importer) {
// Only intercept imports from user code, not from our proxy
if (id === APP_SHELL_PACKAGE && importer && !importer.includes("\0")) {
log.debug(`Intercepting import of ${APP_SHELL_PACKAGE}`);
// When entrypoint is set, only intercept imports from that file
if (resolvedEntrypoint && normalizePath(importer) !== resolvedEntrypoint) {
return null;
}
log.debug(`Intercepting import of ${APP_SHELL_PACKAGE} from ${importer}`);
return VIRTUAL_PROXY_ID;
}
return null;
Expand Down
15 changes: 15 additions & 0 deletions packages/vite-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ export type AppShellRoutesPluginOptions = {
* @default false
*/
generateTypedRoutes?: boolean | TypedRoutesOptions;

/**
* The file that renders the AppShell component (relative to project root).
*
* When specified, only imports from this file are intercepted and replaced
* with the pages-injected AppShell. All other files (including page
* components) import directly from the real package, so there is no
* circular module dependency.
*
* When omitted, all user-code imports of `@tailor-platform/app-shell` are
* intercepted (legacy behavior).
*
* @example "src/App.tsx"
*/
entrypoint?: string;
};

// ============================================
Expand Down
Loading