@multiplatform.one/config 7.6.3 → 7.7.0

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createViteConfig } from "./vite-W9hIeg2_.js";
1
+ import { t as createViteConfig } from "./vite-Rv5b8Wxp.js";
2
2
  import { t as createStorybookViteConfig } from "./storybook-BnfZmzIL.js";
3
3
  import { n as tamaguiWorkspacePathsFile, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
4
4
  import { createVitestConfig } from "./vitest.js";
@@ -92,6 +92,7 @@ function createViteConfig(options = {}) {
92
92
  vitePlugins.push(ssrReactNativeAliasPlugin());
93
93
  vitePlugins.push(clientBrokenEsmPlugin());
94
94
  registerNativeEngineIoBrowserTransports();
95
+ registerNativeSinglePackageInstances();
95
96
  registerNativeWorkspaceEntries();
96
97
  registerNativePhosphorIcons();
97
98
  if (nativeFixes?.protoShadow !== false) registerNativeProtoShadowFix();
@@ -276,6 +277,107 @@ function clientBrokenEsmPlugin() {
276
277
  * This re-asserts the browser redirect with no node_modules patch. Web is
277
278
  * unaffected (engine.io is SSR-externalized and the web client already maps `browser`).
278
279
  */
280
+ /**
281
+ * Collapse duplicate PHYSICAL copies of the same package@version to one path
282
+ * in the NATIVE graph, so React (and every other stateful singleton) exists
283
+ * exactly once in the Hermes bundle.
284
+ *
285
+ * The workspace's node_modules is a MIXED install. `.npmrc` says
286
+ * `node-linker=hoisted`, so the root `node_modules/<pkg>` entries are real
287
+ * directories; but an earlier isolated install left a `node_modules/.pnpm`
288
+ * virtual store plus per-package symlink farms behind — e.g. each package's
289
+ * own `node_modules/react` still points at
290
+ * `../../../node_modules/.pnpm/react@19.2.5/node_modules/react`.
291
+ * Both trees are fully populated with byte-identical copies, and which one an
292
+ * importer reaches depends only on where that importer sits on disk:
293
+ *
294
+ * apps/one/app -> node_modules/.pnpm/react@19.2.5/node_modules/react
295
+ * public/forms/src -> node_modules/react (no local symlink)
296
+ *
297
+ * Rolldown resolves symlinks, so the two land as two DIFFERENT absolute paths
298
+ * and it bundles both. React 19's `useContext` is
299
+ * `ReactSharedInternals.H.useContext(Context)` — `H` is the dispatcher the
300
+ * renderer installs during render, and it is only ever set on the copy the
301
+ * renderer imported. A component from the other copy therefore reads `H` as
302
+ * null and the app dies with "Cannot read property 'useContext' of null".
303
+ * That is the whole bug; the same split had also duplicated react-native (427
304
+ * modules each), @tamagui/web, @react-navigation/core, one, scheduler and 58
305
+ * more packages.
306
+ *
307
+ * The rule: an id that resolves inside the virtual store is rewritten to the
308
+ * hoisted root copy when the root copy exists, carries the SAME version, and
309
+ * actually has the file. Version equality is the safety guard — genuine
310
+ * multi-version installs (two @babel/runtime, two viem) keep both copies.
311
+ *
312
+ * On a correctly installed tree this is a no-op by construction: `hoisted`
313
+ * leaves no `.pnpm` store to match, and `isolated` makes the root entry a
314
+ * symlink INTO the store, which realpaths back to the same directory (checked
315
+ * explicitly). It only fires on the mixed tree, and it is cheap because only
316
+ * BARE specifiers are inspected — once a package's entry is canonical, its own
317
+ * relative imports resolve inside the canonical copy for free.
318
+ */
319
+ function registerNativeSinglePackageInstances() {
320
+ const g = globalThis;
321
+ const name = "vxrn-single-package-instances";
322
+ g.__vxrnAddNativePlugins = g.__vxrnAddNativePlugins ?? [];
323
+ if (g.__vxrnAddNativePlugins.some((p) => p?.name === name)) return;
324
+ const rootModules = path.join(findProjectRoot(), "node_modules");
325
+ const storePath = /[\\/]node_modules[\\/]\.pnpm[\\/][^\\/]+[\\/]node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)(?:[\\/](.*))?$/;
326
+ function versionOf(dir) {
327
+ try {
328
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
329
+ return JSON.parse(raw).version ?? null;
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+ const hoisted = /* @__PURE__ */ new Map();
335
+ function hoistedTwin(pkg, storeDir) {
336
+ if (!hoisted.has(pkg)) {
337
+ const rootDir = path.join(rootModules, pkg);
338
+ let twin = null;
339
+ if (fs.existsSync(rootDir)) {
340
+ const rootVersion = versionOf(rootDir);
341
+ const storeVersion = versionOf(storeDir);
342
+ if (rootVersion && rootVersion === storeVersion) try {
343
+ if (fs.realpathSync(rootDir) !== fs.realpathSync(storeDir)) twin = rootDir;
344
+ } catch {
345
+ twin = null;
346
+ }
347
+ }
348
+ hoisted.set(pkg, twin);
349
+ }
350
+ return hoisted.get(pkg) ?? null;
351
+ }
352
+ const seen = /* @__PURE__ */ new Map();
353
+ g.__vxrnAddNativePlugins.push({
354
+ name,
355
+ async resolveId(id, importer) {
356
+ if (!id || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return null;
357
+ const key = `${id} ${importer ?? ""}`;
358
+ const cached = seen.get(key);
359
+ if (cached !== void 0) return cached;
360
+ let out = null;
361
+ try {
362
+ const resolved = await this.resolve(id, importer, { skipSelf: true });
363
+ const match = resolved?.external ? null : resolved?.id?.match(storePath);
364
+ if (resolved && match) {
365
+ const pkg = match[1].split(path.sep).join("/");
366
+ const rest = match[2] ?? "";
367
+ const twin = hoistedTwin(pkg, resolved.id.slice(0, resolved.id.length - (rest ? rest.length + 1 : 0)));
368
+ if (twin) {
369
+ const candidate = rest ? path.join(twin, rest) : twin;
370
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) out = candidate;
371
+ }
372
+ }
373
+ } catch {
374
+ out = null;
375
+ }
376
+ seen.set(key, out);
377
+ return out;
378
+ }
379
+ });
380
+ }
279
381
  function registerNativeEngineIoBrowserTransports() {
280
382
  const g = globalThis;
281
383
  const name = "vxrn-engineio-browser-transports";
@@ -322,7 +424,12 @@ function registerNativeWorkspaceEntries() {
322
424
  const root = roots.get(id);
323
425
  if (root) {
324
426
  const nativeEntry = path.join(root, "dist/esm/index.native.js");
325
- return fs.existsSync(nativeEntry) ? nativeEntry : null;
427
+ if (fs.existsSync(nativeEntry)) return nativeEntry;
428
+ const srcTs = path.join(root, "src/index.ts");
429
+ if (fs.existsSync(srcTs)) return srcTs;
430
+ const srcTsx = path.join(root, "src/index.tsx");
431
+ if (fs.existsSync(srcTsx)) return srcTsx;
432
+ return null;
326
433
  }
327
434
  if (id.endsWith("/dist/esm/index.mjs")) {
328
435
  const nativeEntry = `${id.slice(0, -4)}.native.js`;
package/lib/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createViteConfig } from "./vite-W9hIeg2_.js";
1
+ import { t as createViteConfig } from "./vite-Rv5b8Wxp.js";
2
2
  import { n as publicPackageViteSourceAliases, t as discoverPublicPackageRoots } from "./workspacePublicPackages-COicQSj4.js";
3
3
 
4
4
  export { createViteConfig, discoverPublicPackageRoots, publicPackageViteSourceAliases };
package/lint.mjs CHANGED
@@ -778,6 +778,65 @@ function checkUndeclaredDrawings() {
778
778
  return { violations, ok: true };
779
779
  }
780
780
 
781
+ /**
782
+ * MPO-109 WORKSPACE PATHS SHADOW — `compilerOptions.paths` REPLACES the
783
+ * inherited map, it never merges with it. `tsconfig.base.json` extends the
784
+ * generated `tamagui-workspace-paths.generated.json`, so one local entry in a
785
+ * package tsconfig hides every `@multiplatform.one/*` mapping from that
786
+ * program and the whole package fails to resolve its own workspace. That is
787
+ * where apps/one's 578 typecheck errors came from, and features, apps/storybook,
788
+ * apps/storybook-expo, apps/uxpin and public/keycloak each carried the same
789
+ * override.
790
+ *
791
+ * The fix for a deep-import alias is the package's own `exports` map, which the
792
+ * generator reads. Escape (rare, for a program that deliberately does not
793
+ * extend the base): `// workspace-paths-escape:` with a reason on the line
794
+ * above `"paths"`.
795
+ */
796
+ function checkTsconfigWorkspacePaths() {
797
+ /** @type {string[]} */
798
+ const violations = [];
799
+ /** @type {string[]} */
800
+ const files = [];
801
+ const featuresTsconfig = join(FEATURES_DIR, "tsconfig.json");
802
+ if (existsSync(featuresTsconfig)) files.push(featuresTsconfig);
803
+ for (const dir of [APPS_DIR, PACKAGES_DIR, PUBLIC_DIR]) {
804
+ if (!existsSync(dir)) continue;
805
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
806
+ if (!entry.isDirectory()) continue;
807
+ const candidate = join(dir, entry.name, "tsconfig.json");
808
+ if (existsSync(candidate)) files.push(candidate);
809
+ }
810
+ }
811
+ for (const file of files) {
812
+ const src = readFileSync(file, "utf8");
813
+ // Whole-line comments only: a `//` inside a string (the $schema URL) must
814
+ // survive, and these files are JSONC, not JSON5.
815
+ const stripped = src.replace(/^[ \t]*\/\/.*$/gm, "");
816
+ let doc;
817
+ try {
818
+ doc = JSON.parse(stripped);
819
+ } catch {
820
+ continue;
821
+ }
822
+ const paths = doc?.compilerOptions?.paths;
823
+ if (!paths || typeof paths !== "object") continue;
824
+ const extendsField = doc.extends;
825
+ const extendsBase = Array.isArray(extendsField)
826
+ ? extendsField.some((e) => typeof e === "string" && e.includes("tsconfig.base"))
827
+ : typeof extendsField === "string" && extendsField.includes("tsconfig.base");
828
+ if (!extendsBase) continue;
829
+ const decl = src.match(/^[ \t]*"paths"\s*:/m);
830
+ const index = decl?.index ?? 0;
831
+ const before = src.slice(0, index);
832
+ if (/workspace-paths-escape:/.test(before.split("\n").slice(-3).join("\n"))) continue;
833
+ violations.push(
834
+ `${relative(ROOT, file)}:${lineAt(src, index)} — declares \`compilerOptions.paths\` (${Object.keys(paths).join(", ")}) while extending tsconfig.base`,
835
+ );
836
+ }
837
+ return violations;
838
+ }
839
+
781
840
  /**
782
841
  * Run structural convention checks over a consumer tree.
783
842
  *
@@ -791,6 +850,7 @@ export function runConventionChecks(options = {}) {
791
850
  const sizeRecipeEscape = checkSizeRecipeEscape();
792
851
  const storyShape = checkStoryFileShape();
793
852
  const undeclaredDrawings = checkUndeclaredDrawings();
853
+ const tsconfigPaths = checkTsconfigWorkspacePaths();
794
854
  const tokens = checkTransitionTokens();
795
855
  const styleTokens = checkStyleTokens();
796
856
  const themeNames = checkThemeNames();
@@ -857,6 +917,15 @@ export function runConventionChecks(options = {}) {
857
917
  console.error("");
858
918
  }
859
919
 
920
+ if (tsconfigPaths.length) {
921
+ failed = true;
922
+ console.error(
923
+ "Convention (MPO-109 WORKSPACE PATHS SHADOW): `compilerOptions.paths` REPLACES the map tsconfig.base.json inherits from tamagui-workspace-paths.generated.json, so one local entry hides every `@multiplatform.one/*` mapping from that program. Declare the alias in the target package's `exports` (the generator reads it) instead. Escape with `// workspace-paths-escape: <reason>` above the key.\n",
924
+ );
925
+ for (const msg of tsconfigPaths) console.error(` ${msg}`);
926
+ console.error("");
927
+ }
928
+
860
929
  if (tokens.violations.length) {
861
930
  failed = true;
862
931
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.6.3",
3
+ "version": "7.7.0",
4
4
  "description": "Shared build and test configuration presets for multiplatform.one",
5
5
  "keywords": [
6
6
  "config",
@@ -102,7 +102,7 @@
102
102
  "vite-plugin-external": "^6.2.2",
103
103
  "vite-plugin-i18next-loader": "^3.1.3",
104
104
  "vitest": "^4.1.5",
105
- "@multiplatform.one/utils": "7.6.3"
105
+ "@multiplatform.one/utils": "7.7.0"
106
106
  },
107
107
  "devDependencies": {
108
108
  "tsdown": "^0.21.10",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The shared `types` array is load-bearing — MPO-109.
3
+ *
4
+ * `public/config/tsconfig/base.json` sets `compilerOptions.types`, which turns
5
+ * OFF automatic @types inclusion for every program in the workspace. Anything
6
+ * missing from that list is simply absent, with no error at the point of loss.
7
+ *
8
+ * `chai` is the one that bit. @vitest/expect declares
9
+ *
10
+ * interface Assertion<T> extends VitestAssertion<Chai.Assertion, T>, ...
11
+ *
12
+ * so with no global `Chai` namespace the base interface collapses and takes
13
+ * `.not` and the jest-dom matchers with it. Measured on 2026-09-04 against
14
+ * origin/main cb157dd0f: public/components 421 -> 26, public/forms 343 -> 4,
15
+ * features 168 -> 138, public/utils 4 -> 0, and no package got worse.
16
+ *
17
+ * Same shape as the workspace `paths` map: a config key that REPLACES rather
18
+ * than merges, quietly dropping what it did not enumerate.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ const baseTsconfig = path.resolve(__dirname, "../tsconfig/base.json");
25
+
26
+ /** JSONC: whole-line `//` comments only, plus trailing commas. */
27
+ function readJsonc(file: string): any {
28
+ const src = fs.readFileSync(file, "utf8");
29
+ return JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
30
+ }
31
+
32
+ describe("base tsconfig types (MPO-109)", () => {
33
+ it("keeps chai in the explicit types array", () => {
34
+ const types = readJsonc(baseTsconfig)?.compilerOptions?.types;
35
+ expect(types).toContain("chai");
36
+ });
37
+
38
+ it("keeps the jest-dom vitest matchers alongside it", () => {
39
+ const types = readJsonc(baseTsconfig)?.compilerOptions?.types;
40
+ expect(types).toContain("@testing-library/jest-dom/vitest");
41
+ });
42
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Emitting programs must not inherit the workspace `paths` map — MPO-109.
3
+ *
4
+ * `tsconfig.base.json` extends `tamagui-workspace-paths.generated.json`, which
5
+ * points every `@multiplatform.one/*` specifier at a sibling's repo-relative
6
+ * `src/`. That is right for a typecheck program and wrong for one that emits
7
+ * declarations under a `rootDir`: a `paths` hit makes the sibling source a file
8
+ * OF THAT PROGRAM, so `rootDir` rejects it —
9
+ *
10
+ * TS6059 File 'public/utils/src/dev.ts' is not under rootDir '.../src'
11
+ * TS6307 ... is not listed within the file list of project ...
12
+ *
13
+ * Every emitting package clears the map and resolves siblings through their
14
+ * exports `source` condition instead (node_modules-external under
15
+ * preserveSymlinks, so `rootDir` never sees them). The three vite plugins had
16
+ * no build config at all and compiled straight off the base, which is how
17
+ * `@multiplatform.one/utils/dev` broke `build-gnome`. The bare-name form was
18
+ * already reachable on main, so this guard exists to stop the next one.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ const root = path.resolve(__dirname, "../../..");
25
+ const publicDir = path.join(root, "public");
26
+
27
+ /** JSONC: whole-line `//` comments only, plus trailing commas. */
28
+ function readJsonc(file: string): any {
29
+ const src = fs.readFileSync(file, "utf8");
30
+ return JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
31
+ }
32
+
33
+ function extendsBase(doc: any): boolean {
34
+ const value = doc?.extends;
35
+ const list = Array.isArray(value) ? value : [value];
36
+ return list.some((entry) => typeof entry === "string" && entry.includes("tsconfig.base"));
37
+ }
38
+
39
+ /** The config each package's `build` script actually hands to tsc. */
40
+ function buildConfigs(): { pkg: string; file: string; doc: any }[] {
41
+ const found: { pkg: string; file: string; doc: any }[] = [];
42
+ for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
43
+ if (!entry.isDirectory()) continue;
44
+ const pkgJson = path.join(publicDir, entry.name, "package.json");
45
+ if (!fs.existsSync(pkgJson)) continue;
46
+ const build = JSON.parse(fs.readFileSync(pkgJson, "utf8"))?.scripts?.build;
47
+ if (typeof build !== "string") continue;
48
+ const named = build.match(/tsc\s+(?:-p|--project)\s+(\S+)/)?.[1];
49
+ const file = path.join(publicDir, entry.name, named ?? "tsconfig.json");
50
+ if (!/\btsc\b/.test(build) || !fs.existsSync(file)) continue;
51
+ found.push({ pkg: entry.name, file, doc: readJsonc(file) });
52
+ }
53
+ return found;
54
+ }
55
+
56
+ describe("emitting tsconfigs (MPO-109)", () => {
57
+ it("finds the build configs", () => {
58
+ expect(buildConfigs().length).toBeGreaterThan(10);
59
+ });
60
+
61
+ it("never lets a rootDir-constrained emitting program inherit the workspace paths map", () => {
62
+ const offenders = buildConfigs()
63
+ .filter(({ doc }) => {
64
+ const co = doc?.compilerOptions ?? {};
65
+ if (co.noEmit === true || co.rootDir === undefined) return false;
66
+ if (!extendsBase(doc)) return false;
67
+ // Clearing the inherited map is the fix; `{}` counts.
68
+ return co.paths === undefined;
69
+ })
70
+ .map(({ pkg, file }) => `${pkg} (${path.relative(root, file)})`);
71
+ expect(offenders).toEqual([]);
72
+ });
73
+
74
+ it("gives every such program the exports `source` condition to resolve siblings with", () => {
75
+ const missing = buildConfigs()
76
+ .filter(({ doc }) => {
77
+ const co = doc?.compilerOptions ?? {};
78
+ if (co.noEmit === true || co.rootDir === undefined) return false;
79
+ if (!extendsBase(doc)) return false;
80
+ return !(co.customConditions ?? []).includes("source");
81
+ })
82
+ .map(({ pkg }) => pkg);
83
+ expect(missing).toEqual([]);
84
+ });
85
+ });
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();
@@ -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);
package/src/vite.ts CHANGED
@@ -224,6 +224,15 @@ export function createViteConfig(options: CreateViteConfigOptions = {}): UserCon
224
224
  // This re-asserts the package's own browser redirect (-> browser transports that
225
225
  // use RN's global WebSocket/XMLHttpRequest) with NO node_modules patch.
226
226
  registerNativeEngineIoBrowserTransports();
227
+ // A mixed pnpm install (hoisted root dirs + a leftover .pnpm store) puts
228
+ // two byte-identical copies of react, react-native and 62 other packages
229
+ // on disk, and which one an importer reaches depends only on where that
230
+ // importer sits. Two Reacts in one Hermes bundle means the renderer arms
231
+ // the dispatcher on one copy while components read it as null from the
232
+ // other — "Cannot read property 'useContext' of null" on first render.
233
+ // It delegates through `this.resolve`, so it canonicalizes whatever the
234
+ // rest of the chain produces, wherever it sits in the plugin order.
235
+ registerNativeSinglePackageInstances();
227
236
  // preferBuiltWorkspaceEntryPlugin (web-only) hard-resolves every workspace
228
237
  // package to its `dist/esm/index.mjs` (WEB build), and that resolution reaches
229
238
  // the native graph too — pinning the whole @multiplatform.one/* chain to web
@@ -562,6 +571,134 @@ function clientBrokenEsmPlugin(): Plugin {
562
571
  * This re-asserts the browser redirect with no node_modules patch. Web is
563
572
  * unaffected (engine.io is SSR-externalized and the web client already maps `browser`).
564
573
  */
574
+ /**
575
+ * Collapse duplicate PHYSICAL copies of the same package@version to one path
576
+ * in the NATIVE graph, so React (and every other stateful singleton) exists
577
+ * exactly once in the Hermes bundle.
578
+ *
579
+ * The workspace's node_modules is a MIXED install. `.npmrc` says
580
+ * `node-linker=hoisted`, so the root `node_modules/<pkg>` entries are real
581
+ * directories; but an earlier isolated install left a `node_modules/.pnpm`
582
+ * virtual store plus per-package symlink farms behind — e.g. each package's
583
+ * own `node_modules/react` still points at
584
+ * `../../../node_modules/.pnpm/react@19.2.5/node_modules/react`.
585
+ * Both trees are fully populated with byte-identical copies, and which one an
586
+ * importer reaches depends only on where that importer sits on disk:
587
+ *
588
+ * apps/one/app -> node_modules/.pnpm/react@19.2.5/node_modules/react
589
+ * public/forms/src -> node_modules/react (no local symlink)
590
+ *
591
+ * Rolldown resolves symlinks, so the two land as two DIFFERENT absolute paths
592
+ * and it bundles both. React 19's `useContext` is
593
+ * `ReactSharedInternals.H.useContext(Context)` — `H` is the dispatcher the
594
+ * renderer installs during render, and it is only ever set on the copy the
595
+ * renderer imported. A component from the other copy therefore reads `H` as
596
+ * null and the app dies with "Cannot read property 'useContext' of null".
597
+ * That is the whole bug; the same split had also duplicated react-native (427
598
+ * modules each), @tamagui/web, @react-navigation/core, one, scheduler and 58
599
+ * more packages.
600
+ *
601
+ * The rule: an id that resolves inside the virtual store is rewritten to the
602
+ * hoisted root copy when the root copy exists, carries the SAME version, and
603
+ * actually has the file. Version equality is the safety guard — genuine
604
+ * multi-version installs (two @babel/runtime, two viem) keep both copies.
605
+ *
606
+ * On a correctly installed tree this is a no-op by construction: `hoisted`
607
+ * leaves no `.pnpm` store to match, and `isolated` makes the root entry a
608
+ * symlink INTO the store, which realpaths back to the same directory (checked
609
+ * explicitly). It only fires on the mixed tree, and it is cheap because only
610
+ * BARE specifiers are inspected — once a package's entry is canonical, its own
611
+ * relative imports resolve inside the canonical copy for free.
612
+ */
613
+ function registerNativeSinglePackageInstances(): void {
614
+ const g = globalThis as unknown as { __vxrnAddNativePlugins?: unknown[] };
615
+ const name = "vxrn-single-package-instances";
616
+ g.__vxrnAddNativePlugins = g.__vxrnAddNativePlugins ?? [];
617
+ if (g.__vxrnAddNativePlugins.some((p) => (p as { name?: string })?.name === name)) return;
618
+
619
+ const rootModules = path.join(findProjectRoot(), "node_modules");
620
+ // <...>/node_modules/.pnpm/<pkg>@<ver>_<peerhash>/node_modules/<name>[/<rest>]
621
+ const storePath =
622
+ /[\\/]node_modules[\\/]\.pnpm[\\/][^\\/]+[\\/]node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)(?:[\\/](.*))?$/;
623
+
624
+ function versionOf(dir: string): string | null {
625
+ try {
626
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
627
+ return (JSON.parse(raw) as { version?: string }).version ?? null;
628
+ } catch {
629
+ return null;
630
+ }
631
+ }
632
+
633
+ // package name -> hoisted root dir that is a safe stand-in, or null
634
+ const hoisted = new Map<string, string | null>();
635
+ function hoistedTwin(pkg: string, storeDir: string): string | null {
636
+ if (!hoisted.has(pkg)) {
637
+ const rootDir = path.join(rootModules, pkg);
638
+ let twin: string | null = null;
639
+ if (fs.existsSync(rootDir)) {
640
+ const rootVersion = versionOf(rootDir);
641
+ const storeVersion = versionOf(storeDir);
642
+ // Same package, same version => identical library code, and the two
643
+ // dirs are genuinely distinct (not one symlinked onto the other).
644
+ if (rootVersion && rootVersion === storeVersion) {
645
+ try {
646
+ if (fs.realpathSync(rootDir) !== fs.realpathSync(storeDir)) twin = rootDir;
647
+ } catch {
648
+ twin = null;
649
+ }
650
+ }
651
+ }
652
+ hoisted.set(pkg, twin);
653
+ }
654
+ return hoisted.get(pkg) ?? null;
655
+ }
656
+
657
+ const seen = new Map<string, string | null>();
658
+
659
+ g.__vxrnAddNativePlugins.push({
660
+ name,
661
+ async resolveId(
662
+ this: {
663
+ resolve: (
664
+ id: string,
665
+ importer: string | undefined,
666
+ opts: { skipSelf: boolean },
667
+ ) => Promise<{ id: string; external?: boolean | string } | null>;
668
+ },
669
+ id: string,
670
+ importer?: string,
671
+ ): Promise<string | null> {
672
+ // Bare specifiers only: a package entry. Relative/absolute ids inside an
673
+ // already-canonical package resolve within that copy on their own.
674
+ if (!id || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return null;
675
+ const key = `${id} ${importer ?? ""}`;
676
+ const cached = seen.get(key);
677
+ if (cached !== undefined) return cached;
678
+
679
+ let out: string | null = null;
680
+ try {
681
+ const resolved = await this.resolve(id, importer, { skipSelf: true });
682
+ const match = resolved?.external ? null : resolved?.id?.match(storePath);
683
+ if (resolved && match) {
684
+ const pkg = match[1].split(path.sep).join("/");
685
+ const rest = match[2] ?? "";
686
+ const storeDir = resolved.id.slice(0, resolved.id.length - (rest ? rest.length + 1 : 0));
687
+ const twin = hoistedTwin(pkg, storeDir);
688
+ if (twin) {
689
+ const candidate = rest ? path.join(twin, rest) : twin;
690
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) out = candidate;
691
+ }
692
+ }
693
+ } catch {
694
+ out = null;
695
+ }
696
+ seen.set(key, out);
697
+ return out;
698
+ },
699
+ });
700
+ }
701
+
565
702
  function registerNativeEngineIoBrowserTransports(): void {
566
703
  const g = globalThis as unknown as { __vxrnAddNativePlugins?: unknown[] };
567
704
  const name = "vxrn-engineio-browser-transports";
@@ -619,8 +756,21 @@ function registerNativeWorkspaceEntries(): void {
619
756
  // (a) bare workspace specifier (e.g. "@multiplatform.one/rich-text")
620
757
  const root = roots.get(id);
621
758
  if (root) {
759
+ // Prefer the built native entry when present (tamagui-build already
760
+ // rewrote intra-package imports to their `.native` siblings).
622
761
  const nativeEntry = path.join(root, "dist/esm/index.native.js");
623
- return fs.existsSync(nativeEntry) ? nativeEntry : null;
762
+ if (fs.existsSync(nativeEntry)) return nativeEntry;
763
+ // Unbuilt workspace package: compile from source instead of letting
764
+ // rolldown leave the bare specifier external — Hermes has no module
765
+ // mode, so a surviving top-level `import` is unparseable. Mirrors
766
+ // preferBuiltWorkspaceEntryPlugin (web) and apps/storybook-expo's
767
+ // Metro resolveWorkspaceNativeMain. vxrn's native resolver already
768
+ // does platform-extension resolution (.native.tsx/.ios.tsx/…).
769
+ const srcTs = path.join(root, "src/index.ts");
770
+ if (fs.existsSync(srcTs)) return srcTs;
771
+ const srcTsx = path.join(root, "src/index.tsx");
772
+ if (fs.existsSync(srcTsx)) return srcTsx;
773
+ return null;
624
774
  }
625
775
  // (b) already-resolved web entry "<...>/dist/esm/index.mjs" -> ".native.js" sibling
626
776
  if (id.endsWith("/dist/esm/index.mjs")) {
@@ -28,7 +28,16 @@
28
28
  "skipLibCheck": true,
29
29
  "strictNullChecks": true,
30
30
  "target": "ES2020",
31
- "types": ["node", "react", "vite/client", "@testing-library/jest-dom/vitest"],
31
+ // An explicit `types` array turns OFF automatic @types inclusion, so this
32
+ // list is the whole set of ambient packages every program in the workspace
33
+ // gets. `chai` is not decoration: @vitest/expect declares
34
+ // interface Assertion<T> extends VitestAssertion<Chai.Assertion, T>, ...
35
+ // and without the global `Chai` namespace that base interface collapses,
36
+ // taking `.not` and the jest-dom matchers with it. That is where MPO-109's
37
+ // ~800 TS2339 came from (public/components 421 -> 26, public/forms
38
+ // 343 -> 4, features 168 -> 138, public/utils 4 -> 0). Do not trim this
39
+ // list without recounting.
40
+ "types": ["node", "react", "vite/client", "@testing-library/jest-dom/vitest", "chai"],
32
41
  "lib": ["DOM", "DOM.Iterable", "ESNext"]
33
42
  },
34
43
  "typeAcquisition": {
@@ -1 +1 @@
1
- {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAU,UAAU,EAAE,MAAM,MAAM,CAAC;AAI/C,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE5B;;OAEG;IACH,OAAO,CAAC,EACJ,OAAO,GACP;QACE;;;WAGG;QACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;QAEtB;;;WAGG;QACH,MAAM,CAAC,EAAE,MAAM,CAAC;QAEhB;;WAEG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IAEN;;OAEG;IACH,IAAI,CAAC,EACD,OAAO,GACP;QACE;;;WAGG;QACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAEjB;;WAEG;QACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;KAC9B,CAAC;IAEN;;;OAGG;IACH,GAAG,CAAC,EACA,OAAO,GACP;QACE,MAAM,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC3B,GAAG,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACtD,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;QAC/B,MAAM,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC1B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAChC,CAAC;IAEN;;OAEG;IACH,GAAG,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC;IAExB;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IAE9B;;OAEG;IACH,OAAO,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;IAEhC;;OAEG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;OAEG;IACH,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;IAEF;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IAEtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAElC;;;OAGG;IACH,WAAW,CAAC,EAAE;QACZ;;;;;;WAMG;QACH,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB;;;;;;;WAOG;QACH,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,UAAU,CAmSlF;AAED,OAAO,EACL,0BAA0B,EAC1B,8BAA8B,GAC/B,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAU,UAAU,EAAE,MAAM,MAAM,CAAC;AAI/C,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE5B;;OAEG;IACH,OAAO,CAAC,EACJ,OAAO,GACP;QACE;;;WAGG;QACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;QAEtB;;;WAGG;QACH,MAAM,CAAC,EAAE,MAAM,CAAC;QAEhB;;WAEG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IAEN;;OAEG;IACH,IAAI,CAAC,EACD,OAAO,GACP;QACE;;;WAGG;QACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAEjB;;WAEG;QACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;KAC9B,CAAC;IAEN;;;OAGG;IACH,GAAG,CAAC,EACA,OAAO,GACP;QACE,MAAM,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC3B,GAAG,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACtD,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;QAC/B,MAAM,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC1B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAChC,CAAC;IAEN;;OAEG;IACH,GAAG,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC;IAExB;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IAE9B;;OAEG;IACH,OAAO,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;IAEhC;;OAEG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;OAEG;IACH,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;IAEF;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IAEtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAElC;;;OAGG;IACH,WAAW,CAAC,EAAE;QACZ;;;;;;WAMG;QACH,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB;;;;;;;WAOG;QACH,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,UAAU,CA4SlF;AAED,OAAO,EACL,0BAA0B,EAC1B,8BAA8B,GAC/B,MAAM,8BAA8B,CAAC"}