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
23 changes: 20 additions & 3 deletions packages/redact/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 './<folder>'` 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<string, keyof ResolvedFeatures> = {
portal: 'portal',
context: 'context',
suspense: 'suspense',
memo: 'memo',
'forward-ref': 'forwardRef',
lazy: 'lazy',
class: 'classComponents',
}

const PRESET_DEFAULTS: Record<RedactPreset, ResolvedFeatures> = {
// Opt-in: everything off. Turn individual features on via `features`.
nano: {
Expand Down Expand Up @@ -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,
})
Expand Down
82 changes: 82 additions & 0 deletions tests/vite-plugin-feature-swap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Vite plugin feature-flag swap test. Verifies the resolveId hook redirects
* `./<folder>` imports from `features/index.ts` to `./<folder>/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<string, boolean>) {
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()
})
})