feat: add support for #super imports in vite-layers

This commit is contained in:
2026-08-13 02:29:44 +07:00
parent 7d98bb00fd
commit 660989085c
14 changed files with 778 additions and 144 deletions
+143 -2
View File
@@ -1,6 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Plugin } from 'vite'
import { createLayeredResolution, layersResolver } from '../src/resolve'
@@ -70,6 +72,55 @@ describe('layersResolver', () => {
})
})
describe('#super — the explicit super() specifier', () => {
const brandHeader = fixture('brand/src/components/Header.vue')
const baseHeader = fixture('base/src/components/Header.vue')
it('bare #super re-resolves the importers own path from the next-lower layer', () => {
expect(resolveId('#super', brandHeader)).toBe(baseHeader)
})
it('#super/<path> resolves any path from strictly below the importers layer', () => {
// the importer is NOT an override of Header — the implicit self-skip can't express this
expect(resolveId('#super/components/Header.vue', fixture('brand/src/main.ts'))).toBe(baseHeader)
})
it('never resolves to the importers own layer (strictly below, not first match)', () => {
expect(resolveId('#super/components/Header.vue', brandHeader)).toBe(baseHeader)
})
it('returns null from the lowest layer / for a path absent below', () => {
expect(resolveId('#super', baseHeader)).toBeNull()
// Footer.vue exists only in base — from a brand importer it resolves DOWN to base's copy…
expect(resolveId('#super/components/Footer.vue', brandHeader)).toBe(fixture('base/src/components/Footer.vue'))
// …but from base itself there is nothing beneath.
expect(resolveId('#super/components/Footer.vue', fixture('base/src/components/Footer.vue'))).toBeNull()
})
it('returns null when the importer is outside the layer stack (or absent)', () => {
expect(resolveId('#super')).toBeNull()
expect(resolveId('#super/components/Header.vue', '/somewhere/else/file.ts')).toBeNull()
})
it('preserves query suffixes on both forms', () => {
expect(resolveId('#super?raw', brandHeader)).toBe(`${baseHeader}?raw`)
expect(resolveId('#super/components/Header.vue?vue&type=style', brandHeader)).toBe(`${baseHeader}?vue&type=style`)
})
it('probes extensions/index like any layered id', () => {
expect(resolveId('#super/widgets/Card', brandHeader)).toBe(fixture('base/src/widgets/Card/index.ts'))
})
it('composes through a deep (3-layer) chain, one step down per layer', () => {
const deepRoots = [fixture('deep/top/src'), fixture('deep/mid/src'), fixture('deep/base/src')]
const dp = layersResolver({ roots: deepRoots })
const W = (layer: string) => fixture(`deep/${layer}/src/components/Widget.vue`)
expect(callResolveId(dp, '#super', W('top'))).toBe(W('mid'))
expect(callResolveId(dp, '#super', W('mid'))).toBe(W('base'))
expect(callResolveId(dp, '#super', W('base'))).toBeNull()
})
})
it('returns null when nothing matches across layers', () => {
expect(resolveId('@/components/Missing.vue')).toBeNull()
})
@@ -87,6 +138,15 @@ describe('layersResolver', () => {
expect(rid('@/components/Header.vue')).toBeNull() // '@/' is not a configured prefix here
})
it('the hook filter matches #super ids but not lookalikes', () => {
const h = plugin.resolveId as { filter: { id: RegExp } }
expect(h.filter.id.test('#super')).toBe(true)
expect(h.filter.id.test('#super/components/Header.vue')).toBe(true)
expect(h.filter.id.test('#super?raw')).toBe(true)
expect(h.filter.id.test('#superstition')).toBe(false)
expect(h.filter.id.test('@/components/Header.vue')).toBe(true)
})
it('caches candidates (repeated resolveId is stable, served from cache)', () => {
const p = layersResolver({ roots })
const rid = (id: string) => callResolveId(p, id)
@@ -103,6 +163,15 @@ describe('createLayeredResolution (introspection core)', () => {
expect(r.parse('#layers/base/x')).toBeNull()
})
it('parse() accepts #super/ as a pseudo-prefix (so devtools can show its candidate stack)', () => {
const r = createLayeredResolution({ roots })
expect(r.parse('#super/components/Header.vue?raw')).toEqual({
prefix: '#super/',
sub: 'components/Header.vue',
query: '?raw',
})
})
it('candidates() lists every matching file across layers, high→low', () => {
const r = createLayeredResolution({ roots })
expect(r.candidates('components/Header.vue')).toEqual([
@@ -151,6 +220,78 @@ describe('createLayeredResolution (introspection core)', () => {
expect(r.records().map(x => x.id)).toEqual(['@/components/Missing.vue', '@/components/Footer.vue'])
})
describe('targeted invalidation (invalidateFile / invalidateDir)', () => {
// Real temp layers — invalidation is about reacting to FS changes, fixtures can't change.
let tmp: string
const setup = () => {
tmp = mkdtempSync(join(tmpdir(), 'vite-layers-inv-'))
const brandSrc = join(tmp, 'brand/src')
const baseSrc = join(tmp, 'base/src')
mkdirSync(join(baseSrc, 'components'), { recursive: true })
mkdirSync(join(brandSrc, 'components'), { recursive: true })
writeFileSync(join(baseSrc, 'components/Button.ts'), 'export default 1')
return { brandSrc, baseSrc, r: createLayeredResolution({ roots: [brandSrc, baseSrc] }) }
}
afterEach(() => rmSync(tmp, { recursive: true, force: true }))
it('a new file in a higher layer changes the winner after invalidateFile', () => {
const { brandSrc, baseSrc, r } = setup()
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts'))) // cached
const override = join(brandSrc, 'components/Button.ts')
writeFileSync(override, 'export default 2')
// control: the stale cache still serves the old winner…
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
r.invalidateFile(override)
// …and the targeted invalidation flips it (extension-probe sub `components/Button` was dropped)
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
})
it('leaves unrelated cache entries warm and ignores files outside every root', () => {
const { brandSrc, baseSrc, r } = setup()
writeFileSync(join(baseSrc, 'components/Other.ts'), 'export default 3')
r.resolveId('@/components/Other')
const before = r.candidates('components/Other')
r.invalidateFile(join(brandSrc, 'components/Button.ts')) // different sub
r.invalidateFile(join(tmp, 'elsewhere/file.ts')) // outside every root — no-op
expect(r.candidates('components/Other')).toBe(before) // same array identity → still cached
})
it('a deleted file falls back to the lower layer after invalidateFile', () => {
const { brandSrc, baseSrc, r } = setup()
const override = join(brandSrc, 'components/Button.ts')
writeFileSync(override, 'export default 2')
expect(r.resolveId('@/components/Button')).toBe(toPosix(override))
unlinkSync(override)
r.invalidateFile(override)
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
})
it('invalidateFile on an index file also drops the bare-dir and dir/index subs', () => {
const { brandSrc, baseSrc, r } = setup()
mkdirSync(join(baseSrc, 'widgets/Card'), { recursive: true })
writeFileSync(join(baseSrc, 'widgets/Card/index.ts'), 'export default 1')
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(baseSrc, 'widgets/Card/index.ts')))
const override = join(brandSrc, 'widgets/Card/index.ts')
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
writeFileSync(override, 'export default 2')
r.invalidateFile(override)
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(override))
})
it('invalidateDir drops everything under the dir including its own index sub', () => {
const { brandSrc, baseSrc, r } = setup()
mkdirSync(join(brandSrc, 'widgets/Card'), { recursive: true })
writeFileSync(join(brandSrc, 'widgets/Card/index.ts'), 'export default 2')
expect(r.resolveId('@/widgets/Card')).toBe(toPosix(join(brandSrc, 'widgets/Card/index.ts')))
expect(r.resolveId('@/components/Button')).toBe(toPosix(join(baseSrc, 'components/Button.ts')))
rmSync(join(brandSrc, 'widgets'), { recursive: true })
r.invalidateDir(join(brandSrc, 'widgets'))
expect(r.resolveId('@/widgets/Card')).toBeNull() // dir gone, nothing below
const warm = r.candidates('components/Button')
expect(r.candidates('components/Button')).toBe(warm) // unrelated entry untouched
})
})
it('the plugin and a shared resolution stay in sync', () => {
const shared = createLayeredResolution({ roots, record: 10 })
const plugin = layersResolver(shared)
+26
View File
@@ -107,6 +107,32 @@ describe('generateTsConfig', () => {
expect(r.dts).toContain(`declare module '#feature'`)
})
it('maps #super/* to the layers BELOW the project layer (explicit super() is typed)', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>
// project layer (app) excluded — super() never resolves to the importer's own layer
expect(paths['#super/*']).toEqual(['../../base/src/*', '../../core/src/*'])
})
it('omits #super/* for a single-layer stack (paths forbids empty substitution arrays)', async () => {
const stack = {
merged: {},
layers: [{ rootDir: fixture('stack/app'), srcDir: resolve(fixture('stack/app'), 'src'), name: 'app', config: {} }],
}
const { tsconfig } = await generateTsConfig(fixture('stack/app'), { stack: stack as never })
expect(tsconfig.compilerOptions!.paths).not.toHaveProperty('#super/*')
})
it('emits an ambient super.d.ts so a bare `import "#super"` type-checks', async () => {
const r = await generateTsConfig(fixture('stack/app'))
expect(r.tsconfig.include).toContain('./super.d.ts')
expect(r.superDtsFile.replace(/\\/g, '/')).toMatch(/\/\.vite-layers\/super\.d\.ts$/)
expect(r.superDts).toContain(`declare module '#super' {}`)
// must stay a SCRIPT file: a top-level import would turn `declare module` into an augmentation
// of an unresolvable specifier (TS2664)
expect(r.superDts).not.toMatch(/^\s*(import|export)\b/m)
})
it('maps #feature to the macro entry so tsc resolves the feature() import', async () => {
const { tsconfig } = await generateTsConfig(fixture('stack/app'))
const paths = tsconfig.compilerOptions!.paths as Record<string, string[]>