diff --git a/.gitignore b/.gitignore index 3b6cc969..d273ece6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ coverage *.tsbuildinfo .turbo + +# AI generated plans from developer loop +docs/plans \ No newline at end of file diff --git a/packages/appkit/src/utils/tests/vite-config-merge.test.ts b/packages/appkit/src/utils/tests/vite-config-merge.test.ts new file mode 100644 index 00000000..bb3ffa20 --- /dev/null +++ b/packages/appkit/src/utils/tests/vite-config-merge.test.ts @@ -0,0 +1,103 @@ +import type { Plugin } from "vite"; +import { describe, expect, test } from "vitest"; +import { mergeConfigDedup } from "../vite-config-merge"; + +const plugin = (name: string): Plugin => ({ name, enforce: "pre" }); + +const simpleMerge = (a: Plugin, b: Plugin) => ({ ...a, ...b }); + +describe("mergeConfigDedup", () => { + test("deduplicates plugins by name, keeping the first occurrence", () => { + const base = { plugins: [plugin("a"), plugin("b")] }; + const override = { plugins: [plugin("b"), plugin("c")] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual(["a", "b", "c"]); + }); + + test("returns merged config when no plugins on either side", () => { + const result = mergeConfigDedup({ x: 1 }, { y: 2 }, simpleMerge); + expect(result).toEqual({ x: 1, y: 2 }); + expect(result.plugins).toBeUndefined(); + }); + + test("preserves plugins when only base has them", () => { + const base = { plugins: [plugin("a")] }; + const override = { other: true }; + + const result = mergeConfigDedup(base, override, simpleMerge); + // mergeFn merges everything; dedup branch only runs when both have plugins + expect(result.plugins).toBeDefined(); + }); + + test("flattens array-returning plugins (e.g. @tailwindcss/vite)", () => { + const tailwindPreset = [ + plugin("tw:base"), + plugin("tw:scan"), + plugin("tw:generate"), + ]; + const base = { plugins: [tailwindPreset] }; + const override = { plugins: [plugin("react")] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual([ + "tw:base", + "tw:scan", + "tw:generate", + "react", + ]); + }); + + test("deduplicates across flattened array plugins and single plugins", () => { + const preset = [plugin("shared"), plugin("unique-a")]; + const base = { plugins: [preset] }; + const override = { plugins: [plugin("shared"), plugin("unique-b")] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual([ + "shared", + "unique-a", + "unique-b", + ]); + }); + + test("handles deeply nested plugin arrays", () => { + const deep = [[plugin("deep-a")], [[plugin("deep-b")]]]; + const base = { plugins: [deep] }; + const override = { plugins: [plugin("top")] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual([ + "deep-a", + "deep-b", + "top", + ]); + }); + + test("filters out false/null/undefined plugin entries", () => { + const base = { plugins: [plugin("a"), false, null] }; + const override = { plugins: [undefined, plugin("b")] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual(["a", "b"]); + }); + + test("handles mixed falsy and array plugins", () => { + const preset = [plugin("tw:base"), plugin("tw:scan")]; + const base = { plugins: [false, preset, null] }; + const override = { plugins: [undefined, plugin("react"), false] }; + + const result = mergeConfigDedup(base, override, simpleMerge); + + expect(result.plugins.map((p: Plugin) => p.name)).toEqual([ + "tw:base", + "tw:scan", + "react", + ]); + }); +}); diff --git a/packages/appkit/src/utils/vite-config-merge.ts b/packages/appkit/src/utils/vite-config-merge.ts index 81bcf634..e3c6b0f1 100644 --- a/packages/appkit/src/utils/vite-config-merge.ts +++ b/packages/appkit/src/utils/vite-config-merge.ts @@ -1,5 +1,9 @@ import type { Plugin } from "vite"; +function flattenPlugins(plugins: any[]): Plugin[] { + return plugins.flat(Infinity).filter(Boolean); +} + export function mergeConfigDedup( base: any, override: any, @@ -8,14 +12,13 @@ export function mergeConfigDedup( const merged = mergeFn(base, override); if (base.plugins && override.plugins) { const seen = new Set(); - merged.plugins = [...base.plugins, ...override.plugins].filter( - (p: Plugin) => { - const name = p.name; - if (seen.has(name)) return false; - seen.add(name); - return true; - }, - ); + const allPlugins = flattenPlugins([...base.plugins, ...override.plugins]); + merged.plugins = allPlugins.filter((p) => { + const name = p.name; + if (seen.has(name)) return false; + seen.add(name); + return true; + }); } return merged; }