@multiplatform.one/config 7.6.3 → 7.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +2 -2
- package/lib/{storybook-BnfZmzIL.js → storybook-BuO1e8Sb.js} +98 -1
- package/lib/storybook.js +1 -1
- package/lib/{vite-W9hIeg2_.js → vite-uwqTTN8M.js} +115 -2
- package/lib/vite.js +1 -1
- package/lint.mjs +448 -2
- package/package.json +2 -2
- package/src/baseTsconfigTypes.spec.ts +42 -0
- package/src/emittingTsconfigPaths.spec.ts +85 -0
- package/src/lint.spec.ts +379 -0
- package/src/singletonDepAliases.spec.ts +83 -0
- package/src/singletonDepAliases.ts +105 -0
- package/src/storybook.ts +16 -0
- package/src/tamaguiWorkspacePaths.spec.ts +34 -0
- package/src/vite.ts +165 -1
- package/tsconfig/base.json +10 -1
- package/types/singletonDepAliases.d.ts +12 -0
- package/types/singletonDepAliases.d.ts.map +1 -0
- package/types/storybook.d.ts.map +1 -1
- package/types/vite.d.ts.map +1 -1
package/src/lint.spec.ts
CHANGED
|
@@ -51,6 +51,73 @@ describe("runConventionChecks", () => {
|
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
describe("workspace paths shadow (MPO-109)", () => {
|
|
55
|
+
function run(root: string) {
|
|
56
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
57
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
58
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
59
|
+
const code = runConventionChecks({ roots: { root } });
|
|
60
|
+
const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
|
|
61
|
+
error.mockRestore();
|
|
62
|
+
log.mockRestore();
|
|
63
|
+
warn.mockRestore();
|
|
64
|
+
return { code, messages };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("fails an app tsconfig that declares paths while extending tsconfig.base", () => {
|
|
68
|
+
const root = makeTree();
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(root, "apps", "demo", "tsconfig.json"),
|
|
71
|
+
`{
|
|
72
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
73
|
+
"extends": "../../tsconfig.base.json",
|
|
74
|
+
"compilerOptions": {
|
|
75
|
+
"paths": { "@/*": ["./*"] }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
);
|
|
80
|
+
const { code, messages } = run(root);
|
|
81
|
+
expect(code).toBe(1);
|
|
82
|
+
expect(messages).toContain("WORKSPACE PATHS SHADOW");
|
|
83
|
+
expect(messages).toContain("apps/demo/tsconfig.json");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("passes with no paths, with an escape comment, and when the config does not extend the base", () => {
|
|
87
|
+
const root = makeTree();
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(root, "apps", "demo", "tsconfig.json"),
|
|
90
|
+
`{
|
|
91
|
+
"extends": "../../tsconfig.base.json",
|
|
92
|
+
"compilerOptions": { "noEmit": true }
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
);
|
|
96
|
+
mkdirSync(join(root, "features", "escaped"), { recursive: true });
|
|
97
|
+
mkdirSync(join(root, "public", "standalone"), { recursive: true });
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(root, "public", "standalone", "tsconfig.json"),
|
|
100
|
+
`{
|
|
101
|
+
"compilerOptions": { "paths": { "@/*": ["./*"] } }
|
|
102
|
+
}
|
|
103
|
+
`,
|
|
104
|
+
);
|
|
105
|
+
mkdirSync(join(root, "packages", "escaped"), { recursive: true });
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(root, "packages", "escaped", "tsconfig.json"),
|
|
108
|
+
`{
|
|
109
|
+
"extends": "../../tsconfig.base.json",
|
|
110
|
+
"compilerOptions": {
|
|
111
|
+
// workspace-paths-escape: this program deliberately owns its own map
|
|
112
|
+
"paths": { "@/*": ["./*"] }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
`,
|
|
116
|
+
);
|
|
117
|
+
expect(run(root).code).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
54
121
|
describe("story file shape (MPO-93)", () => {
|
|
55
122
|
function storyTree() {
|
|
56
123
|
const root = makeTree();
|
|
@@ -172,3 +239,315 @@ describe("undeclared drawing file (MPO-17)", () => {
|
|
|
172
239
|
expect(code).toBe(0);
|
|
173
240
|
});
|
|
174
241
|
});
|
|
242
|
+
|
|
243
|
+
describe("metro subpath fallback (MPO-185)", () => {
|
|
244
|
+
function pkgTree(pkg: Record<string, unknown>) {
|
|
245
|
+
const root = makeTree();
|
|
246
|
+
mkdirSync(join(root, "public", "pkg", "src"), { recursive: true });
|
|
247
|
+
writeFileSync(join(root, "public", "pkg", "src", "thing.ts"), "export const thing = 1;\n");
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(root, "public", "pkg", "package.json"),
|
|
250
|
+
JSON.stringify({ name: "@multiplatform.one/pkg", ...pkg }, null, 2),
|
|
251
|
+
);
|
|
252
|
+
return root;
|
|
253
|
+
}
|
|
254
|
+
function run(root: string) {
|
|
255
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
256
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
257
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
258
|
+
const code = runConventionChecks({ roots: { root } });
|
|
259
|
+
const lines = error.mock.calls.map((c) => String(c[0]));
|
|
260
|
+
error.mockRestore();
|
|
261
|
+
log.mockRestore();
|
|
262
|
+
warn.mockRestore();
|
|
263
|
+
return { code, lines };
|
|
264
|
+
}
|
|
265
|
+
const sourceConsumed = {
|
|
266
|
+
source: "src/index.ts",
|
|
267
|
+
main: "src/index.ts",
|
|
268
|
+
exports: {
|
|
269
|
+
"./package.json": "./package.json",
|
|
270
|
+
"./src/*": "./src/*",
|
|
271
|
+
".": { source: "./src/index.ts", import: "./dist/esm/index.mjs" },
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
it("fails a source-consumed subpath whose Metro condition points into dist/ with no fallback file", () => {
|
|
276
|
+
const root = pkgTree({
|
|
277
|
+
...sourceConsumed,
|
|
278
|
+
exports: {
|
|
279
|
+
...sourceConsumed.exports,
|
|
280
|
+
"./thing": {
|
|
281
|
+
source: "./src/thing.ts",
|
|
282
|
+
types: "./types/thing.d.ts",
|
|
283
|
+
import: "./dist/esm/thing.mjs",
|
|
284
|
+
require: "./dist/cjs/thing.cjs",
|
|
285
|
+
default: "./src/thing.ts",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const { code, lines } = run(root);
|
|
290
|
+
expect(code).toBe(1);
|
|
291
|
+
expect(lines.join("\n")).toMatch(/MPO-185 METRO SUBPATH FALLBACK/);
|
|
292
|
+
expect(lines.join("\n")).toMatch(
|
|
293
|
+
/public\/pkg\/package\.json — "\.\/thing" resolves to \.\/dist\/esm\/thing\.mjs on ios/,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("fails when only the nested react-native condition points into dist/", () => {
|
|
298
|
+
const root = pkgTree({
|
|
299
|
+
...sourceConsumed,
|
|
300
|
+
exports: {
|
|
301
|
+
...sourceConsumed.exports,
|
|
302
|
+
"./thing": {
|
|
303
|
+
source: "./src/thing.ts",
|
|
304
|
+
"react-native": { import: "./dist/esm/thing.native.js" },
|
|
305
|
+
default: "./src/thing.ts",
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const { code, lines } = run(root);
|
|
310
|
+
expect(code).toBe(1);
|
|
311
|
+
expect(lines.join("\n")).toMatch(/resolves to \.\/dist\/esm\/thing\.native\.js on ios/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("passes once <pkg>/<subpath>.ts exists on disk", () => {
|
|
315
|
+
const root = pkgTree({
|
|
316
|
+
...sourceConsumed,
|
|
317
|
+
exports: {
|
|
318
|
+
...sourceConsumed.exports,
|
|
319
|
+
"./thing": { source: "./src/thing.ts", import: "./dist/esm/thing.mjs" },
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
writeFileSync(join(root, "public", "pkg", "thing.ts"), 'export * from "./src/thing";\n');
|
|
323
|
+
expect(run(root).code).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("passes a subpath whose first Metro condition already lands in src/ on every platform", () => {
|
|
327
|
+
const root = pkgTree({
|
|
328
|
+
...sourceConsumed,
|
|
329
|
+
exports: {
|
|
330
|
+
...sourceConsumed.exports,
|
|
331
|
+
"./thing": { source: "./src/thing.ts", default: "./src/thing.ts" },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
expect(run(root).code).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("skips a built package with no source condition (keycloak-js shape) and ignores `.` and wildcards", () => {
|
|
338
|
+
const root = pkgTree({
|
|
339
|
+
main: "./dist/index.js",
|
|
340
|
+
exports: {
|
|
341
|
+
".": { types: "./dist/index.d.ts", import: "./dist/index.js", default: "./dist/index.js" },
|
|
342
|
+
"./authz": { types: "./dist/authz.d.ts", import: "./dist/authz.js" },
|
|
343
|
+
"./dist/*": "./dist/*",
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
expect(run(root).code).toBe(0);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("react-native-web export guard (MPO-199)", () => {
|
|
351
|
+
/**
|
|
352
|
+
* The vocabulary comes from the INSTALLED react-native-web, so the fixture
|
|
353
|
+
* ships its own `dist/index.js` — that is also the proof the check reads the
|
|
354
|
+
* list rather than carrying a hardcoded guess.
|
|
355
|
+
*/
|
|
356
|
+
function rnwTree(exportNames: string[] = ["Platform", "Pressable", "View"]) {
|
|
357
|
+
const root = makeTree();
|
|
358
|
+
const dist = join(root, "node_modules", "react-native-web", "dist");
|
|
359
|
+
mkdirSync(dist, { recursive: true });
|
|
360
|
+
writeFileSync(
|
|
361
|
+
join(dist, "index.js"),
|
|
362
|
+
`${exportNames
|
|
363
|
+
.map((name) => `export { default as ${name} } from './exports/${name}';`)
|
|
364
|
+
.join("\n")}\n`,
|
|
365
|
+
);
|
|
366
|
+
mkdirSync(join(root, "public", "forms", "src", "fields", "Select"), { recursive: true });
|
|
367
|
+
return root;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function run(root: string) {
|
|
371
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
372
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
373
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
374
|
+
const code = runConventionChecks({ roots: { root } });
|
|
375
|
+
const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
|
|
376
|
+
error.mockRestore();
|
|
377
|
+
log.mockRestore();
|
|
378
|
+
warn.mockRestore();
|
|
379
|
+
return { code, messages };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const selectFile = join("public", "forms", "src", "fields", "Select", "index.tsx");
|
|
383
|
+
|
|
384
|
+
it("fails the historic Select import (2b94d9eeb) and names the file, line and missing export", () => {
|
|
385
|
+
const root = rnwTree();
|
|
386
|
+
writeFileSync(
|
|
387
|
+
join(root, selectFile),
|
|
388
|
+
`import { useState } from "react";
|
|
389
|
+
import { ActionSheetIOS, Platform, Pressable as RNPressable } from "react-native";
|
|
390
|
+
|
|
391
|
+
export const Select = () => (ActionSheetIOS && Platform && RNPressable ? useState : null);
|
|
392
|
+
`,
|
|
393
|
+
);
|
|
394
|
+
const { code, messages } = run(root);
|
|
395
|
+
expect(code).toBe(1);
|
|
396
|
+
expect(messages).toContain("REACT-NATIVE-WEB EXPORT");
|
|
397
|
+
expect(messages).toContain(`${selectFile}:2 — ActionSheetIOS`);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("passes the namespace read (8c62c2a6f), a type-only import, aliases and exported names", () => {
|
|
401
|
+
const root = rnwTree();
|
|
402
|
+
writeFileSync(
|
|
403
|
+
join(root, selectFile),
|
|
404
|
+
`import * as ReactNative from "react-native";
|
|
405
|
+
import { Platform, Pressable as RNPressable } from "react-native";
|
|
406
|
+
import type { GestureResponderEvent } from "react-native";
|
|
407
|
+
import { View, type LayoutChangeEvent } from "react-native";
|
|
408
|
+
|
|
409
|
+
export const sheet = (ReactNative as { ActionSheetIOS?: unknown }).ActionSheetIOS;
|
|
410
|
+
export const parts = { Platform, RNPressable, View };
|
|
411
|
+
export type Events = GestureResponderEvent | LayoutChangeEvent;
|
|
412
|
+
`,
|
|
413
|
+
);
|
|
414
|
+
expect(run(root).code).toBe(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("never flags a platform-suffixed file the web resolver cannot reach", () => {
|
|
418
|
+
const root = rnwTree();
|
|
419
|
+
mkdirSync(join(root, "public", "rich-text", "src", "toolbar"), { recursive: true });
|
|
420
|
+
writeFileSync(
|
|
421
|
+
join(root, "public", "rich-text", "src", "toolbar", "ColorPicker.native.tsx"),
|
|
422
|
+
'import { ActionSheetIOS, Alert, Platform } from "react-native";\nexport const p = { ActionSheetIOS, Alert, Platform };\n',
|
|
423
|
+
);
|
|
424
|
+
writeFileSync(
|
|
425
|
+
join(root, "public", "forms", "src", "fields", "Select", "index.ios.tsx"),
|
|
426
|
+
'import { ActionSheetIOS } from "react-native";\nexport const p = ActionSheetIOS;\n',
|
|
427
|
+
);
|
|
428
|
+
expect(run(root).code).toBe(0);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("does not attribute a neighbouring import's names to react-native", () => {
|
|
432
|
+
const root = rnwTree();
|
|
433
|
+
writeFileSync(
|
|
434
|
+
join(root, selectFile),
|
|
435
|
+
`import { useCallback } from "react";
|
|
436
|
+
import { CaretDownIcon } from "@phosphor-icons/react";
|
|
437
|
+
import { Platform } from "react-native";
|
|
438
|
+
|
|
439
|
+
export const parts = { useCallback, CaretDownIcon, Platform };
|
|
440
|
+
`,
|
|
441
|
+
);
|
|
442
|
+
expect(run(root).code).toBe(0);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("no import.meta in public/*/src (MPO-204)", () => {
|
|
447
|
+
function run(root: string) {
|
|
448
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
449
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
450
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
451
|
+
const code = runConventionChecks({ roots: { root } });
|
|
452
|
+
const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
|
|
453
|
+
error.mockRestore();
|
|
454
|
+
log.mockRestore();
|
|
455
|
+
warn.mockRestore();
|
|
456
|
+
return { code, messages };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function publicSrc(root: string, pkg: string, name: string, body: string) {
|
|
460
|
+
mkdirSync(join(root, "public", pkg, "src"), { recursive: true });
|
|
461
|
+
writeFileSync(join(root, "public", pkg, "src", name), body);
|
|
462
|
+
return join("public", pkg, "src", name);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
it("fails a live import.meta.env read and names the file and line", () => {
|
|
466
|
+
const root = makeTree();
|
|
467
|
+
const file = publicSrc(
|
|
468
|
+
root,
|
|
469
|
+
"frappe",
|
|
470
|
+
"useLiveQuery.ts",
|
|
471
|
+
`const IS_SSR: boolean = (() => {
|
|
472
|
+
try {
|
|
473
|
+
return (import.meta as ImportMeta & { env?: { SSR?: boolean } }).env?.SSR === true;
|
|
474
|
+
} catch {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
})();
|
|
478
|
+
export default IS_SSR;
|
|
479
|
+
`,
|
|
480
|
+
);
|
|
481
|
+
const { code, messages } = run(root);
|
|
482
|
+
expect(code).toBe(1);
|
|
483
|
+
expect(messages).toContain("NO import.meta IN public/*/src");
|
|
484
|
+
expect(messages).toContain(`${file}:3`);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("ignores the token inside comments and strings, which is how the fix documents itself", () => {
|
|
488
|
+
const root = makeTree();
|
|
489
|
+
publicSrc(
|
|
490
|
+
root,
|
|
491
|
+
"platform",
|
|
492
|
+
"runtimeConfig.ts",
|
|
493
|
+
`/**
|
|
494
|
+
* Build-time config with no import.meta anywhere: in a classic script
|
|
495
|
+
* import.meta is a parse-time SyntaxError.
|
|
496
|
+
*/
|
|
497
|
+
// a bare \`typeof import.meta\` guard is NOT replaced
|
|
498
|
+
export const why = "import.meta is banned here";
|
|
499
|
+
export function bakedEnv() {
|
|
500
|
+
try {
|
|
501
|
+
return { VITE_MP_CONFIG: process.env.VITE_MP_CONFIG };
|
|
502
|
+
} catch {
|
|
503
|
+
return {};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
`,
|
|
507
|
+
);
|
|
508
|
+
expect(run(root).code).toBe(0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("exempts Node-only build tooling by construction: a value import of a node: builtin", () => {
|
|
512
|
+
const root = makeTree();
|
|
513
|
+
publicSrc(
|
|
514
|
+
root,
|
|
515
|
+
"config",
|
|
516
|
+
"vitest.ts",
|
|
517
|
+
`import path from "node:path";
|
|
518
|
+
import { fileURLToPath } from "node:url";
|
|
519
|
+
|
|
520
|
+
export const here = path.dirname(fileURLToPath(import.meta.url));
|
|
521
|
+
`,
|
|
522
|
+
);
|
|
523
|
+
expect(run(root).code).toBe(0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("does NOT let a type-only node: import buy the exemption", () => {
|
|
527
|
+
const root = makeTree();
|
|
528
|
+
const file = publicSrc(
|
|
529
|
+
root,
|
|
530
|
+
"components",
|
|
531
|
+
"widget.ts",
|
|
532
|
+
`import type { PathLike } from "node:fs";
|
|
533
|
+
|
|
534
|
+
export const mode = (import.meta as ImportMeta & { env?: { MODE?: string } }).env?.MODE;
|
|
535
|
+
export type Where = PathLike;
|
|
536
|
+
`,
|
|
537
|
+
);
|
|
538
|
+
const { code, messages } = run(root);
|
|
539
|
+
expect(code).toBe(1);
|
|
540
|
+
expect(messages).toContain(`${file}:3`);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("exempts specs, which vitest runs in node and hands a real import.meta.env", () => {
|
|
544
|
+
const root = makeTree();
|
|
545
|
+
publicSrc(
|
|
546
|
+
root,
|
|
547
|
+
"markdown",
|
|
548
|
+
"nativeGraph.spec.ts",
|
|
549
|
+
'export const dir = new URL(".", import.meta.url).pathname;\n',
|
|
550
|
+
);
|
|
551
|
+
expect(run(root).code).toBe(0);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { createStorybookViteConfig } from "./storybook";
|
|
5
|
+
import { singletonDepAliases } from "./singletonDepAliases";
|
|
6
|
+
|
|
7
|
+
const workspaceRoot = path.resolve(__dirname, "../../..");
|
|
8
|
+
|
|
9
|
+
describe("singletonDepAliases", () => {
|
|
10
|
+
it("pins react-cookie to one existing package directory (MPO-215)", () => {
|
|
11
|
+
// react-cookie runs React.createContext(null) at module scope. Two
|
|
12
|
+
// realpaths for it means two contexts: @multiplatform.one/storybook's
|
|
13
|
+
// framework decorator renders <CookiesProvider> from one and
|
|
14
|
+
// @multiplatform.one/theme's useTheme reads useCookies from the other,
|
|
15
|
+
// which throws "Missing <CookiesProvider>" with the provider sitting two
|
|
16
|
+
// lines above it in the same tree.
|
|
17
|
+
const aliases = singletonDepAliases(workspaceRoot);
|
|
18
|
+
expect(aliases["react-cookie"]).toBeTruthy();
|
|
19
|
+
expect(path.isAbsolute(aliases["react-cookie"])).toBe(true);
|
|
20
|
+
expect(fs.existsSync(path.join(aliases["react-cookie"], "package.json"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("pins the tamagui theme record to one package directory (MPO-217)", () => {
|
|
24
|
+
// @tamagui/web owns the theme context. Two realpaths for it means two
|
|
25
|
+
// contexts: public/frappe's story files import "tamagui" from a package
|
|
26
|
+
// with no copy of its own, so they reach the hoisted chain while every
|
|
27
|
+
// other importer reaches the pnpm store chain, and their components read
|
|
28
|
+
// a context no provider ever wrote to — tamagui's `Missing theme.`.
|
|
29
|
+
const aliases = singletonDepAliases(workspaceRoot);
|
|
30
|
+
for (const pkg of ["@tamagui/core", "@tamagui/web"]) {
|
|
31
|
+
expect(aliases[pkg], `${pkg} must be pinned`).toBeTruthy();
|
|
32
|
+
expect(path.isAbsolute(aliases[pkg])).toBe(true);
|
|
33
|
+
expect(fs.existsSync(path.join(aliases[pkg], "package.json"))).toBe(true);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not pin tamagui itself (MPO-217)", () => {
|
|
38
|
+
// A directory alias would resolve "tamagui/linear-gradient" to the
|
|
39
|
+
// metro-compat stub that requires the CJS build, instead of the ESM
|
|
40
|
+
// target the exports map picks for the browser. Pinning core and web is
|
|
41
|
+
// enough: both tamagui records import @tamagui/core by bare specifier.
|
|
42
|
+
expect(singletonDepAliases(workspaceRoot).tamagui).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("omits packages that are not installed", () => {
|
|
46
|
+
// Resolution runs against the given root, so a consumer without the
|
|
47
|
+
// package gets no alias rather than a broken one.
|
|
48
|
+
const aliases = singletonDepAliases(path.join(workspaceRoot, "public", "config"));
|
|
49
|
+
for (const target of Object.values(aliases)) {
|
|
50
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("createStorybookViteConfig react-cookie singleton", () => {
|
|
56
|
+
const config = createStorybookViteConfig({ workspaceRoot });
|
|
57
|
+
|
|
58
|
+
it("aliases react-cookie so the build cannot resolve two module records", () => {
|
|
59
|
+
// resolve.dedupe does NOT cover this. Measured against Vite 8 / Rolldown:
|
|
60
|
+
// a build carrying dedupe: ["react-cookie"] emitted a byte-identical
|
|
61
|
+
// iframe chunk and the story still threw. The alias is what moves it.
|
|
62
|
+
const alias = (config.resolve?.alias ?? {}) as Record<string, string>;
|
|
63
|
+
expect(alias["react-cookie"]).toBeTruthy();
|
|
64
|
+
expect(path.isAbsolute(alias["react-cookie"])).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("aliases the tamagui theme record so the build cannot split it (MPO-217)", () => {
|
|
68
|
+
const alias = (config.resolve?.alias ?? {}) as Record<string, string>;
|
|
69
|
+
for (const pkg of ["@tamagui/core", "@tamagui/web"]) {
|
|
70
|
+
expect(alias[pkg], `${pkg} must be aliased`).toBeTruthy();
|
|
71
|
+
expect(path.isAbsolute(alias[pkg])).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
expect(alias.tamagui).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("pre-bundles react-cookie in dev so dev and build share one record", () => {
|
|
77
|
+
// The dev server hid this bug for as long as it existed: the optimizer
|
|
78
|
+
// keys pre-bundled deps by package NAME, so both realpaths collapsed onto
|
|
79
|
+
// one deps/react-cookie.js and every story rendered. Listing it keeps dev
|
|
80
|
+
// explicit about the same invariant the build now pins by alias.
|
|
81
|
+
expect(config.optimizeDeps?.include ?? []).toContain("react-cookie");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Packages that create a React context at module scope and must therefore
|
|
7
|
+
* resolve to exactly ONE module record across the whole bundle.
|
|
8
|
+
*
|
|
9
|
+
* MPO-215. `react-cookie` runs `React.createContext(null)` when its entry
|
|
10
|
+
* evaluates. `@multiplatform.one/storybook` renders `<CookiesProvider>` in the
|
|
11
|
+
* framework decorator and `@multiplatform.one/theme`'s `useTheme` reads it via
|
|
12
|
+
* `useCookies`, so provider and consumer live in two different workspace
|
|
13
|
+
* packages. When those two packages resolve `react-cookie` to two different
|
|
14
|
+
* realpaths — a hoisted `node_modules/react-cookie` for the importer that does
|
|
15
|
+
* not declare it, the pnpm store copy for the one that does — the entry
|
|
16
|
+
* evaluates twice, there are two contexts, and `useCookies` throws
|
|
17
|
+
* `Missing <CookiesProvider>` with the provider sitting two lines above it in
|
|
18
|
+
* the same tree.
|
|
19
|
+
*
|
|
20
|
+
* Nothing catches that today. The dev server pre-bundles bare imports by
|
|
21
|
+
* package NAME, which collapses both realpaths onto one `deps/react-cookie.js`
|
|
22
|
+
* and renders fine; the production build has no optimizer, so both paths
|
|
23
|
+
* survive, every story renders Storybook's error panel, and
|
|
24
|
+
* `storybook build` still exits 0.
|
|
25
|
+
*
|
|
26
|
+
* `resolve.dedupe` does not fix it: measured against Vite 8 / Rolldown, a
|
|
27
|
+
* build with `dedupe: ["react-cookie"]` emitted a byte-identical iframe chunk
|
|
28
|
+
* and the story still threw. An alias to one absolute directory does, which is
|
|
29
|
+
* the same instrument the storybook config already uses to pin react,
|
|
30
|
+
* react-dom and react-is.
|
|
31
|
+
*
|
|
32
|
+
* MPO-217. `@tamagui/web` is the same shape. It owns the theme context, the
|
|
33
|
+
* style registry and the config singleton; `@tamagui/core` re-exports it and
|
|
34
|
+
* `tamagui` re-exports that, so every tamagui component in the tree reads the
|
|
35
|
+
* theme through whichever `@tamagui/web` record its own import chain reached.
|
|
36
|
+
*
|
|
37
|
+
* Measured on the build's module graph at e19a0fda3: `tamagui` resolved to
|
|
38
|
+
* two realpaths. 562 importers (public/components, public/backoffice,
|
|
39
|
+
* public/storybook and the rest) took the pnpm store copy, whose chain is
|
|
40
|
+
* store `tamagui` -> store `@tamagui/core` -> store `@tamagui/web`; five took
|
|
41
|
+
* the hoisted `node_modules/tamagui`, which has no nested node_modules and so
|
|
42
|
+
* walks up to the hoisted `@tamagui/core` and `@tamagui/web`. Those five were
|
|
43
|
+
* public/frappe's story files — the only modules in the repo that import
|
|
44
|
+
* `tamagui` from a package with no copy of its own — and their 14 stories were
|
|
45
|
+
* the 14 that threw `Missing theme.` on storybook-static while rendering on
|
|
46
|
+
* dev. The theme provider is mounted from the store record; their `Button`,
|
|
47
|
+
* `Text` and `YStack` read the hoisted record's context, which no provider
|
|
48
|
+
* ever wrote to.
|
|
49
|
+
*
|
|
50
|
+
* `tamagui` itself is deliberately NOT pinned. A directory alias resolves
|
|
51
|
+
* `tamagui/linear-gradient` to `node_modules/tamagui/linear-gradient/`, a
|
|
52
|
+
* metro-compat stub that `require`s the CJS build, instead of the ESM target
|
|
53
|
+
* the package's exports map picks for the browser. Pinning core and web is
|
|
54
|
+
* enough: both `tamagui` records import `@tamagui/core` by bare specifier, so
|
|
55
|
+
* they funnel into one web record and one theme context.
|
|
56
|
+
*/
|
|
57
|
+
const CONTEXT_SINGLETONS: readonly string[] = ["react-cookie", "@tamagui/core", "@tamagui/web"];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Vite `resolve.alias` entries pinning each context singleton to one directory.
|
|
61
|
+
*
|
|
62
|
+
* Alias keys match the whole specifier or a `key + "/"` prefix, so subpath
|
|
63
|
+
* imports follow the same copy instead of resolving independently. Entries are
|
|
64
|
+
* omitted when the package is not installed, so a consumer that does not use
|
|
65
|
+
* cookies is unaffected.
|
|
66
|
+
*
|
|
67
|
+
* @param root Directory whose `node_modules` the packages resolve from.
|
|
68
|
+
*/
|
|
69
|
+
export function singletonDepAliases(root: string = process.cwd()): Record<string, string> {
|
|
70
|
+
const aliases: Record<string, string> = {};
|
|
71
|
+
const requireFrom = createRequire(path.join(root, "package.json"));
|
|
72
|
+
|
|
73
|
+
for (const pkgName of CONTEXT_SINGLETONS) {
|
|
74
|
+
const pkgDir = resolvePackageDir(pkgName, root, requireFrom);
|
|
75
|
+
if (pkgDir) aliases[pkgName] = pkgDir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return aliases;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolvePackageDir(
|
|
82
|
+
pkgName: string,
|
|
83
|
+
root: string,
|
|
84
|
+
requireFrom: NodeRequire,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
return path.dirname(requireFrom.resolve(`${pkgName}/package.json`));
|
|
88
|
+
} catch {
|
|
89
|
+
// react-cookie 8's exports map declares only ".", so "./package.json" is
|
|
90
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED. Resolve the entry instead and walk up to
|
|
91
|
+
// the directory that owns it — the pnpm store copy when nothing is
|
|
92
|
+
// hoisted, which is exactly the copy that must win.
|
|
93
|
+
try {
|
|
94
|
+
let dir = path.dirname(requireFrom.resolve(pkgName));
|
|
95
|
+
while (dir !== path.dirname(dir)) {
|
|
96
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
97
|
+
dir = path.dirname(dir);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// not installed
|
|
101
|
+
}
|
|
102
|
+
const hoisted = path.join(root, "node_modules", ...pkgName.split("/"));
|
|
103
|
+
return fs.existsSync(path.join(hoisted, "package.json")) ? hoisted : undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/storybook.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { Plugin, UserConfig } from "vite";
|
|
4
|
+
import { singletonDepAliases } from "./singletonDepAliases.js";
|
|
4
5
|
import { unexportedDepAliases } from "./unexportedDepAliases.js";
|
|
5
6
|
import { discoverPublicPackageRoots, resolvePackageMainSource } from "./workspacePublicPackages.js";
|
|
6
7
|
import { ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths.js";
|
|
@@ -59,6 +60,9 @@ export function createStorybookViteConfig(
|
|
|
59
60
|
...discoverPackageAliases(packagesDir),
|
|
60
61
|
...discoverPublicPackageViteAliases(workspaceRoot),
|
|
61
62
|
...unexportedDepAliases(workspaceRoot),
|
|
63
|
+
// MPO-215: last, so a context singleton can never be shadowed by a
|
|
64
|
+
// discovered workspace alias.
|
|
65
|
+
...singletonDepAliases(workspaceRoot),
|
|
62
66
|
};
|
|
63
67
|
|
|
64
68
|
if (options.aliases) {
|
|
@@ -171,6 +175,18 @@ export function createStorybookViteConfig(
|
|
|
171
175
|
"@tamagui/themes",
|
|
172
176
|
"@tamagui/toast",
|
|
173
177
|
"@tamagui/use-presence",
|
|
178
|
+
// MPO-215: keep dev on the same single record the build now pins by
|
|
179
|
+
// alias, so a cookie-context bug can never be dev-only again.
|
|
180
|
+
"react-cookie",
|
|
181
|
+
// Subpath, not a package: public/components/src/tamagui.ts re-exports
|
|
182
|
+
// LinearGradient from "tamagui/linear-gradient" (MPO-21 G2), so the
|
|
183
|
+
// story graph never reaches "@tamagui/linear-gradient" above. Without
|
|
184
|
+
// this line the subpath is optimized in whatever generation discovers
|
|
185
|
+
// it, carries its own tamagui core registry, and
|
|
186
|
+
// components-lineargradient--default renders 0 nodes with "Can't find
|
|
187
|
+
// Tamagui configuration" plus the duplicate-instances warning.
|
|
188
|
+
// "@tamagui/config/v5" above is the same shape.
|
|
189
|
+
"tamagui/linear-gradient",
|
|
174
190
|
"@mdx-js/react",
|
|
175
191
|
"i18next",
|
|
176
192
|
"react-i18next",
|
|
@@ -70,6 +70,40 @@ describe("ensureTamaguiWorkspacePaths", () => {
|
|
|
70
70
|
expect(notices[0]).toContain("was missing");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it("emits the subpath exports a package declares (MPO-109)", () => {
|
|
74
|
+
const { root, configDir } = fakeRepo(fs.readFileSync(realGenerator, "utf8"));
|
|
75
|
+
fs.mkdirSync(path.join(root, "public/widget/src/desk"), { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(root, "public/widget/src/dev.ts"), "export const dev = 1;\n");
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(root, "public/widget/src/desk/Shell.tsx"),
|
|
79
|
+
"export const Shell = 1;\n",
|
|
80
|
+
);
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
path.join(root, "public/widget/package.json"),
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
name: "@fake/widget",
|
|
85
|
+
exports: {
|
|
86
|
+
".": { source: "./src/index.ts" },
|
|
87
|
+
// condition object: the declared `source`, never the gitignored types/
|
|
88
|
+
"./dev": { source: "./src/dev.ts", types: "./types/dev.d.ts" },
|
|
89
|
+
// wildcard string target: an app deep-imports through this prefix
|
|
90
|
+
"./src/*": "./src/*",
|
|
91
|
+
// declared but absent on disk — must not reach the map
|
|
92
|
+
"./ghost": { source: "./src/ghost.ts" },
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
const generatedPath = path.join(configDir, tamaguiWorkspacePathsFile);
|
|
97
|
+
const result = ensureTamaguiWorkspacePaths({ configPackageDir: configDir, log: () => {} });
|
|
98
|
+
expect(result.status).toBe("generated");
|
|
99
|
+
const { paths } = JSON.parse(fs.readFileSync(generatedPath, "utf8")).compilerOptions;
|
|
100
|
+
expect(paths["@fake/widget"]).toEqual(["../../widget/src/index.ts"]);
|
|
101
|
+
expect(paths["@fake/widget/dev"]).toEqual(["../../widget/src/dev.ts"]);
|
|
102
|
+
expect(paths["@fake/widget/src/*"]).toEqual(["../../widget/src/*"]);
|
|
103
|
+
expect(paths).not.toHaveProperty("@fake/widget/ghost");
|
|
104
|
+
expect(paths).not.toHaveProperty("@fake/widget/package.json");
|
|
105
|
+
});
|
|
106
|
+
|
|
73
107
|
it("leaves an existing file alone and stays silent", () => {
|
|
74
108
|
const { configDir } = fakeRepo(fs.readFileSync(realGenerator, "utf8"));
|
|
75
109
|
const generatedPath = path.join(configDir, tamaguiWorkspacePathsFile);
|