diff --git a/packages/redact/src/vite/index.ts b/packages/redact/src/vite/index.ts index b089fb3..c04af96 100644 --- a/packages/redact/src/vite/index.ts +++ b/packages/redact/src/vite/index.ts @@ -82,6 +82,22 @@ interface ResolvedFeatures { hydration: boolean } +// On-disk folder names (kebab-case) → ResolvedFeatures keys (camelCase). The +// resolveId hook receives the bare specifier from `features/index.ts`'s +// `import './'` calls, so the matched name is always the folder. +// Without this remap, `forwardRef: false` and `classComponents: false` were +// silently no-ops because `'forward-ref' in features` and `'class' in features` +// are both false. +const FOLDER_TO_FEATURE: Record = { + portal: 'portal', + context: 'context', + suspense: 'suspense', + memo: 'memo', + 'forward-ref': 'forwardRef', + lazy: 'lazy', + class: 'classComponents', +} + const PRESET_DEFAULTS: Record = { // Opt-in: everything off. Turn individual features on via `features`. nano: { @@ -373,9 +389,10 @@ export function redact(options: RedactOptions = {}): any { if (importer && /[\\/]features[\\/]index\.[jt]sx?$/.test(importer)) { const m = id.match(/^\.\/([a-z-]+)$/) if (m) { - const name = m[1] as keyof ResolvedFeatures - if (name in features && !features[name]) { - const r = await this.resolve(`./${name}/stub`, importer, { + const folder = m[1]! + const name = FOLDER_TO_FEATURE[folder] + if (name && !features[name]) { + const r = await this.resolve(`./${folder}/stub`, importer, { ...opts, skipSelf: true, }) diff --git a/tests/vite-plugin-feature-swap.test.ts b/tests/vite-plugin-feature-swap.test.ts new file mode 100644 index 0000000..701fa81 --- /dev/null +++ b/tests/vite-plugin-feature-swap.test.ts @@ -0,0 +1,82 @@ +/** + * Vite plugin feature-flag swap test. Verifies the resolveId hook redirects + * `./` imports from `features/index.ts` to `.//stub` when the + * matching feature flag is `false`. + * + * Regression: kebab-case folders (`forward-ref`, `class`) were previously + * cast directly to `keyof ResolvedFeatures` (camelCase), so `'forward-ref' in + * features` was always false and the swap never happened — silently keeping + * the full implementation in the bundle even when the user opted out. + */ +import { describe, it, expect } from 'vitest' +import { redact } from '@tanstack/redact/vite' + +const featuresIndex = '/abs/path/packages/redact/src/dom/features/index.ts' + +interface ResolveCall { + id: string + importer: string + opts: unknown +} + +function makePlugin(features: Record) { + const plugin: any = redact({ preset: 'full', features }) + const calls: ResolveCall[] = [] + // Mimic Vite's resolveId context — the hook is called as a method, so `this` + // must expose `resolve` and `environment.name`. + const ctx = { + environment: { name: 'client' }, + resolve(id: string, importer: string, opts: unknown) { + calls.push({ id, importer, opts }) + return { id: `RESOLVED::${id}` } + }, + } + return { plugin, ctx, calls } +} + +describe('redact() Vite plugin — feature flag → stub swap', () => { + it('swaps ./forward-ref to ./forward-ref/stub when forwardRef=false', async () => { + const { plugin, ctx, calls } = makePlugin({ forwardRef: false }) + const result = await plugin.resolveId.call(ctx, './forward-ref', featuresIndex, {}) + expect(calls).toHaveLength(1) + expect(calls[0]!.id).toBe('./forward-ref/stub') + expect(result).toBe('RESOLVED::./forward-ref/stub') + }) + + it('swaps ./class to ./class/stub when classComponents=false', async () => { + const { plugin, ctx, calls } = makePlugin({ classComponents: false }) + const result = await plugin.resolveId.call(ctx, './class', featuresIndex, {}) + expect(calls).toHaveLength(1) + expect(calls[0]!.id).toBe('./class/stub') + expect(result).toBe('RESOLVED::./class/stub') + }) + + it('swaps ./portal, ./context, ./suspense, ./memo, ./lazy when their flags are false', async () => { + const cases = ['portal', 'context', 'suspense', 'memo', 'lazy'] + for (const folder of cases) { + const { plugin, ctx, calls } = makePlugin({ [folder]: false }) + const result = await plugin.resolveId.call(ctx, `./${folder}`, featuresIndex, {}) + expect(calls[0]!.id).toBe(`./${folder}/stub`) + expect(result).toBe(`RESOLVED::./${folder}/stub`) + } + }) + + it('does NOT swap when the feature flag is true (or unset, defaulting to full preset)', async () => { + const { plugin, ctx, calls } = makePlugin({ forwardRef: true, classComponents: true }) + const r1 = await plugin.resolveId.call(ctx, './forward-ref', featuresIndex, {}) + const r2 = await plugin.resolveId.call(ctx, './class', featuresIndex, {}) + expect(calls).toHaveLength(0) + // Falls through to the resolvedMap lookup (no match for these specifiers + // because they aren't in ALIASES) → null. + expect(r1).toBeNull() + expect(r2).toBeNull() + }) + + it('skips the swap entirely in the rsc environment', async () => { + const { plugin, ctx, calls } = makePlugin({ forwardRef: false }) + ;(ctx as any).environment.name = 'rsc' + const result = await plugin.resolveId.call(ctx, './forward-ref', featuresIndex, {}) + expect(calls).toHaveLength(0) + expect(result).toBeNull() + }) +})