Skip to content

Commit c7cd8fe

Browse files
serpentbladeclaude
andcommitted
fix(vue): emit non-collapsing base for untyped null-default prop (260623-kks)
An optional prop authored `{ type: null, default: null }` lowers to `{ kind: 'identifier', name: 'unknown' }`, so renderPropField widened it to `unknown | null`. TS collapses `unknown | null` -> `unknown`, and Vue's `withDefaults` `InferDefault<P, unknown>` then rejects the emitted `null` default -> TS2322 (29x in the data-table Vue leaf dogfood). renderPropField now substitutes `Record<string, any>` for the collapsing `needsNull && baseType === 'unknown'` case so `Record<string, any> | null` keeps both union branches and withDefaults accepts the null default. Gated to that case only; Function null-defaults and all other fields stay byte-identical. Red-first oracle nullDefaultUnknownProp.test.ts: string-shape assertion + strict vue-tsc 0-TS2322 oracle (incl. a Function-prop non-regression leg). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JE6gykywo57CcqUJZTsB9
1 parent 86645b9 commit c7cd8fe

2 files changed

Lines changed: 169 additions & 1 deletion

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Quick task 260623-kks — Vue emitter null-default hardening.
3+
*
4+
* Root cause: an untyped optional prop authored `{ type: null, default: null }`
5+
* lowers to `{ kind: 'identifier', name: 'unknown' }`. `renderPropField` widens
6+
* it to `unknown | null`; TS collapses `unknown | null` → `unknown`; Vue's
7+
* `withDefaults` `InferDefault<P, unknown>` resolves the default-slot type to
8+
* `((props) => {}) | undefined`, which REJECTS the emitted `null` literal default
9+
* inside `withDefaults(..., { obj: null })` → TS2322.
10+
*
11+
* RED-first oracle: this file is written and run BEFORE the emitter fix. Both
12+
* assertions must FAIL on the current emitter, then PASS after the surgical
13+
* `renderPropField` change (gate `needsNull && baseType === 'unknown'` →
14+
* non-collapsing base).
15+
*
16+
* - Assertion A (string shape): the emitted `obj?:` field is NOT the collapsing
17+
* `unknown | null` — it carries a non-collapsing base that keeps both union
18+
* branches.
19+
* - Assertion B (strict vue-tsc oracle, load-bearing): write the emitted SFC to
20+
* a tmpdir, run `vue-tsc --noEmit` under a STRICT tsconfig, assert ZERO TS2322.
21+
* The Function prop (`fn`, `{ type: Function, default: null }`) must ALSO
22+
* produce 0 TS2322 — it already emits `((...args: any[]) => any) | null` and
23+
* must stay byte-identical (non-regression leg).
24+
*/
25+
import { describe, it, expect } from 'vitest';
26+
import { execFileSync } from 'node:child_process';
27+
import {
28+
mkdtempSync,
29+
rmSync,
30+
writeFileSync,
31+
symlinkSync,
32+
existsSync,
33+
} from 'node:fs';
34+
import { join, resolve, dirname } from 'node:path';
35+
import { tmpdir } from 'node:os';
36+
import { fileURLToPath } from 'node:url';
37+
import { parse } from '../../../../../core/src/parse.js';
38+
import { lowerToIR } from '../../../../../core/src/ir/lower.js';
39+
import { createDefaultRegistry } from '../../../../../core/src/modifiers/registerBuiltins.js';
40+
import { emitVue } from '../../emitVue.js';
41+
42+
const HERE = dirname(fileURLToPath(import.meta.url));
43+
const ROOT = resolve(HERE, '../../../../../..');
44+
45+
// vue-tsc is NOT a direct devDep of @rozie/target-vue (W2). Resolve it from the
46+
// tests/vue-typecheck harness package, which depends on it and has vue resolvable.
47+
const VUE_TYPECHECK_DIR = resolve(ROOT, 'tests/vue-typecheck');
48+
const VUE_TSC_BIN = resolve(VUE_TYPECHECK_DIR, 'node_modules/.bin/vue-tsc');
49+
50+
function compile(rozieSrc: string): string {
51+
const { ast } = parse(rozieSrc, { filename: 'Test.rozie' });
52+
if (!ast) throw new Error('parse() returned null');
53+
const { ir } = lowerToIR(ast, { modifierRegistry: createDefaultRegistry() });
54+
if (!ir) throw new Error('lowerToIR() returned null');
55+
const result = emitVue(ir, { filename: 'Test.rozie', source: rozieSrc });
56+
return result.code;
57+
}
58+
59+
const STRICT_TSCONFIG = JSON.stringify({
60+
compilerOptions: {
61+
target: 'ES2022',
62+
module: 'ESNext',
63+
moduleResolution: 'bundler',
64+
jsx: 'preserve',
65+
strict: true,
66+
noImplicitAny: true,
67+
strictNullChecks: true,
68+
skipLibCheck: true,
69+
isolatedModules: true,
70+
esModuleInterop: true,
71+
allowSyntheticDefaultImports: true,
72+
noEmit: true,
73+
lib: ['ES2022', 'DOM'],
74+
types: ['vue'],
75+
},
76+
include: ['**/*.vue'],
77+
});
78+
79+
/**
80+
* Write the emitted SFC into a tmpdir alongside a STRICT tsconfig, symlink the
81+
* vue-typecheck harness's node_modules so `vue` + its types resolve, run
82+
* `vue-tsc --noEmit`, and return the captured stdout+stderr (vue-tsc writes
83+
* diagnostics to stdout on non-zero, throwing via execFileSync).
84+
*/
85+
function strictVueTsc(sfc: string, name = 'Probe.vue'): string {
86+
const tmpDir = mkdtempSync(join(tmpdir(), 'rozie-nulldefault-'));
87+
try {
88+
writeFileSync(join(tmpDir, name), sfc);
89+
writeFileSync(join(tmpDir, 'tsconfig.json'), STRICT_TSCONFIG);
90+
symlinkSync(
91+
join(VUE_TYPECHECK_DIR, 'node_modules'),
92+
join(tmpDir, 'node_modules'),
93+
'dir',
94+
);
95+
try {
96+
execFileSync(VUE_TSC_BIN, ['--noEmit', '-p', 'tsconfig.json'], {
97+
cwd: tmpDir,
98+
stdio: 'pipe',
99+
});
100+
return ''; // clean
101+
} catch (err) {
102+
const stdout = (err as { stdout?: Buffer }).stdout?.toString() ?? '';
103+
const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? '';
104+
return stdout + '\n' + stderr;
105+
}
106+
} finally {
107+
rmSync(tmpDir, { recursive: true, force: true });
108+
}
109+
}
110+
111+
function countTS2322(diag: string): number {
112+
return (diag.match(/TS2322/g) ?? []).length;
113+
}
114+
115+
describe('Vue emitter — null-default untyped prop (260623-kks)', () => {
116+
it('precondition: vue-tsc binary is resolvable from the harness', () => {
117+
expect(existsSync(VUE_TSC_BIN)).toBe(true);
118+
});
119+
120+
const SRC = `<rozie name="Probe">
121+
<props>
122+
{
123+
obj: { type: null, default: null },
124+
fn: { type: Function, default: null }
125+
}
126+
</props>
127+
<template>
128+
<div></div>
129+
</template>
130+
</rozie>`;
131+
132+
it('Assertion A — obj field is NOT the collapsing `unknown | null`', () => {
133+
const code = compile(SRC);
134+
// The emitted defineProps field for `obj` must not collapse to unknown.
135+
expect(code).not.toContain('obj?: unknown | null');
136+
// It must keep a non-collapsing base with both union branches.
137+
expect(code).toMatch(/obj\?: [^;}]*\| null/);
138+
});
139+
140+
it('Assertion B — strict vue-tsc of the emitted SFC has ZERO TS2322 (obj + fn)', () => {
141+
const code = compile(SRC);
142+
const diag = strictVueTsc(code);
143+
const n = countTS2322(diag);
144+
if (n !== 0) {
145+
// Surface the captured diagnostics on failure for fast triage.
146+
throw new Error(
147+
`Expected 0 TS2322 in strict vue-tsc of the emitted SFC, got ${n}:\n${diag}`,
148+
);
149+
}
150+
expect(n).toBe(0);
151+
});
152+
});

packages/targets/vue/src/emit/emitScript.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,25 @@ function hasExplicitNullDefault(prop: PropDecl): boolean {
323323
* `| null` to avoid a double suffix.
324324
*/
325325
function renderPropField(p: PropDecl): string {
326-
const baseType = renderType(p.typeAnnotation);
326+
let baseType = renderType(p.typeAnnotation);
327327
const needsNull =
328328
hasExplicitNullDefault(p) && !/\|\s*null$/.test(baseType.trimEnd());
329+
// Quick task 260623-kks — `{ type: null, default: null }` (an untyped
330+
// object-passthrough prop) lowers to `{ kind: 'identifier', name: 'unknown' }`,
331+
// so `renderType` returns the literal string `'unknown'`. Widening that to
332+
// `unknown | null` is a TRAP: TS collapses `unknown | null` → `unknown`, and
333+
// Vue's `withDefaults` `InferDefault<P, unknown>` then resolves the default
334+
// slot to `((props) => {}) | undefined`, which REJECTS the `null` default in
335+
// the generated `withDefaults(..., { obj: null })` object → TS2322 (29× in the
336+
// data-table Vue leaf dogfood). Substitute a NON-collapsing object-ish base
337+
// (`Record<string, any>` — the same type `renderType` already emits for
338+
// `Object`/`'object'`): `Record<string, any> | null` keeps both union
339+
// branches, is opaque-passthrough-ergonomic, and parallels the working
340+
// Function case `((...args: any[]) => any) | null`. Type-only fix; gated to
341+
// the collapsing case so every other field stays byte-identical.
342+
if (needsNull && baseType === 'unknown') {
343+
baseType = 'Record<string, any>';
344+
}
329345
const finalType = needsNull ? `${baseType} | null` : baseType;
330346
// 260521-oao — `p.required` is the SOLE optionality determinant: a
331347
// `required: true` prop drops the `?` and emits a non-optional field.

0 commit comments

Comments
 (0)