@multiplatform.one/config 7.7.0 → 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 CHANGED
@@ -1,5 +1,5 @@
1
- import { t as createViteConfig } from "./vite-Rv5b8Wxp.js";
2
- import { t as createStorybookViteConfig } from "./storybook-BnfZmzIL.js";
1
+ import { t as createViteConfig } from "./vite-uwqTTN8M.js";
2
+ import { t as createStorybookViteConfig } from "./storybook-BuO1e8Sb.js";
3
3
  import { n as tamaguiWorkspacePathsFile, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-ZIcqybtn.js";
4
4
  import { createVitestConfig } from "./vitest.js";
5
5
 
@@ -4,6 +4,100 @@ import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
 
7
+ //#region src/singletonDepAliases.ts
8
+ /**
9
+ * Packages that create a React context at module scope and must therefore
10
+ * resolve to exactly ONE module record across the whole bundle.
11
+ *
12
+ * MPO-215. `react-cookie` runs `React.createContext(null)` when its entry
13
+ * evaluates. `@multiplatform.one/storybook` renders `<CookiesProvider>` in the
14
+ * framework decorator and `@multiplatform.one/theme`'s `useTheme` reads it via
15
+ * `useCookies`, so provider and consumer live in two different workspace
16
+ * packages. When those two packages resolve `react-cookie` to two different
17
+ * realpaths — a hoisted `node_modules/react-cookie` for the importer that does
18
+ * not declare it, the pnpm store copy for the one that does — the entry
19
+ * evaluates twice, there are two contexts, and `useCookies` throws
20
+ * `Missing <CookiesProvider>` with the provider sitting two lines above it in
21
+ * the same tree.
22
+ *
23
+ * Nothing catches that today. The dev server pre-bundles bare imports by
24
+ * package NAME, which collapses both realpaths onto one `deps/react-cookie.js`
25
+ * and renders fine; the production build has no optimizer, so both paths
26
+ * survive, every story renders Storybook's error panel, and
27
+ * `storybook build` still exits 0.
28
+ *
29
+ * `resolve.dedupe` does not fix it: measured against Vite 8 / Rolldown, a
30
+ * build with `dedupe: ["react-cookie"]` emitted a byte-identical iframe chunk
31
+ * and the story still threw. An alias to one absolute directory does, which is
32
+ * the same instrument the storybook config already uses to pin react,
33
+ * react-dom and react-is.
34
+ *
35
+ * MPO-217. `@tamagui/web` is the same shape. It owns the theme context, the
36
+ * style registry and the config singleton; `@tamagui/core` re-exports it and
37
+ * `tamagui` re-exports that, so every tamagui component in the tree reads the
38
+ * theme through whichever `@tamagui/web` record its own import chain reached.
39
+ *
40
+ * Measured on the build's module graph at e19a0fda3: `tamagui` resolved to
41
+ * two realpaths. 562 importers (public/components, public/backoffice,
42
+ * public/storybook and the rest) took the pnpm store copy, whose chain is
43
+ * store `tamagui` -> store `@tamagui/core` -> store `@tamagui/web`; five took
44
+ * the hoisted `node_modules/tamagui`, which has no nested node_modules and so
45
+ * walks up to the hoisted `@tamagui/core` and `@tamagui/web`. Those five were
46
+ * public/frappe's story files — the only modules in the repo that import
47
+ * `tamagui` from a package with no copy of its own — and their 14 stories were
48
+ * the 14 that threw `Missing theme.` on storybook-static while rendering on
49
+ * dev. The theme provider is mounted from the store record; their `Button`,
50
+ * `Text` and `YStack` read the hoisted record's context, which no provider
51
+ * ever wrote to.
52
+ *
53
+ * `tamagui` itself is deliberately NOT pinned. A directory alias resolves
54
+ * `tamagui/linear-gradient` to `node_modules/tamagui/linear-gradient/`, a
55
+ * metro-compat stub that `require`s the CJS build, instead of the ESM target
56
+ * the package's exports map picks for the browser. Pinning core and web is
57
+ * enough: both `tamagui` records import `@tamagui/core` by bare specifier, so
58
+ * they funnel into one web record and one theme context.
59
+ */
60
+ const CONTEXT_SINGLETONS = [
61
+ "react-cookie",
62
+ "@tamagui/core",
63
+ "@tamagui/web"
64
+ ];
65
+ /**
66
+ * Vite `resolve.alias` entries pinning each context singleton to one directory.
67
+ *
68
+ * Alias keys match the whole specifier or a `key + "/"` prefix, so subpath
69
+ * imports follow the same copy instead of resolving independently. Entries are
70
+ * omitted when the package is not installed, so a consumer that does not use
71
+ * cookies is unaffected.
72
+ *
73
+ * @param root Directory whose `node_modules` the packages resolve from.
74
+ */
75
+ function singletonDepAliases(root = process.cwd()) {
76
+ const aliases = {};
77
+ const requireFrom = createRequire(path.join(root, "package.json"));
78
+ for (const pkgName of CONTEXT_SINGLETONS) {
79
+ const pkgDir = resolvePackageDir$1(pkgName, root, requireFrom);
80
+ if (pkgDir) aliases[pkgName] = pkgDir;
81
+ }
82
+ return aliases;
83
+ }
84
+ function resolvePackageDir$1(pkgName, root, requireFrom) {
85
+ try {
86
+ return path.dirname(requireFrom.resolve(`${pkgName}/package.json`));
87
+ } catch {
88
+ try {
89
+ let dir = path.dirname(requireFrom.resolve(pkgName));
90
+ while (dir !== path.dirname(dir)) {
91
+ if (fs.existsSync(path.join(dir, "package.json"))) return dir;
92
+ dir = path.dirname(dir);
93
+ }
94
+ } catch {}
95
+ const hoisted = path.join(root, "node_modules", ...pkgName.split("/"));
96
+ return fs.existsSync(path.join(hoisted, "package.json")) ? hoisted : void 0;
97
+ }
98
+ }
99
+
100
+ //#endregion
7
101
  //#region src/unexportedDepAliases.ts
8
102
  /**
9
103
  * Package subtrees that consumers deep-import but the publisher never declared
@@ -95,7 +189,8 @@ function createStorybookViteConfig(options = {}) {
95
189
  const aliases = {
96
190
  ...discoverPackageAliases(path.join(workspaceRoot, "packages")),
97
191
  ...discoverPublicPackageViteAliases(workspaceRoot),
98
- ...unexportedDepAliases(workspaceRoot)
192
+ ...unexportedDepAliases(workspaceRoot),
193
+ ...singletonDepAliases(workspaceRoot)
99
194
  };
100
195
  if (options.aliases) Object.assign(aliases, options.aliases);
101
196
  const sortedAliases = {};
@@ -157,6 +252,8 @@ function createStorybookViteConfig(options = {}) {
157
252
  "@tamagui/themes",
158
253
  "@tamagui/toast",
159
254
  "@tamagui/use-presence",
255
+ "react-cookie",
256
+ "tamagui/linear-gradient",
160
257
  "@mdx-js/react",
161
258
  "i18next",
162
259
  "react-i18next",
package/lib/storybook.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as createStorybookViteConfig } from "./storybook-BnfZmzIL.js";
1
+ import { t as createStorybookViteConfig } from "./storybook-BuO1e8Sb.js";
2
2
 
3
3
  export { createStorybookViteConfig };
@@ -190,7 +190,13 @@ function createViteConfig(options = {}) {
190
190
  return {
191
191
  css: { modules: { localsConvention: "camelCase" } },
192
192
  build: { chunkSizeWarningLimit: 600 },
193
- define: { "process.env.VITE_FRAPPE_ENABLED": JSON.stringify(process.env.VITE_FRAPPE_ENABLED === "true" ? "true" : "false") },
193
+ define: {
194
+ "process.env.VITE_FRAPPE_ENABLED": JSON.stringify(process.env.VITE_FRAPPE_ENABLED === "true" ? "true" : "false"),
195
+ ...publicConfigKeys.length > 0 ? {
196
+ "process.env.VITE_MP_CONFIG": JSON.stringify(process.env.VITE_MP_CONFIG ?? ""),
197
+ "process.env.VITE_MP_PUBLIC_CONFIG_KEYS": JSON.stringify(process.env.VITE_MP_PUBLIC_CONFIG_KEYS ?? "")
198
+ } : {}
199
+ },
194
200
  ssr: ssr || defaultSsr,
195
201
  resolve: {
196
202
  alias: {
package/lib/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as createViteConfig } from "./vite-Rv5b8Wxp.js";
1
+ import { t as createViteConfig } from "./vite-uwqTTN8M.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
@@ -27,6 +27,22 @@
27
27
  * whose path is not a DRAWING member in docs/theme-propagation-spec.md.
28
28
  * The oxlint rule skips registered drawings; this check is what makes
29
29
  * adding an undeclared drawing file still fail.
30
+ * 7. Fail on a METRO SUBPATH WITHOUT A FALLBACK (MPO-185) — a package.json
31
+ * subpath export in a source-consumed package whose Metro condition points
32
+ * into `dist/` with no `<pkg>/<subpath>.*` file on disk. Metro never
33
+ * reconsiders the map's `source` condition after the dist miss, so the
34
+ * first import of that subpath fails the whole bundle.
35
+ * 8. Fail on a REACT-NATIVE NAMED IMPORT WITH NO REACT-NATIVE-WEB EXPORT
36
+ * (MPO-199) — a `import { X } from "react-native"` in a file the web
37
+ * target resolves, where react-native-web exports no X. The alias makes
38
+ * that a module-evaluation SyntaxError, which takes the whole preview
39
+ * bundle down rather than one component.
40
+ * 9. Fail on an `import.meta` TOKEN in public/<pkg>/src (MPO-204) — in a
41
+ * classic script it is a PARSE-time SyntaxError, and two bundles execute
42
+ * mpo as one (the vxrn/rolldown Hermes bundle, and any Metro bundle for
43
+ * platform=web, i.e. an Expo DOM component). Node-only build tooling is
44
+ * exempt by construction: a file that imports a `node:` builtin cannot run
45
+ * in a browser or on Hermes anyway.
30
46
  *
31
47
  * Usage:
32
48
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -253,12 +269,16 @@ function keysOfBlock(body, indent) {
253
269
  let sourceFileCache;
254
270
  function sourceFiles() {
255
271
  if (sourceFileCache) return sourceFileCache;
256
- /** @type {{ rel: string, src: string }[]} */
272
+ /** @type {{ abs: string, rel: string, src: string }[]} */
257
273
  const files = [];
258
274
  const collect = (filePath) => {
259
275
  if (!/\.(tsx|ts|jsx|js)$/.test(filePath)) return;
260
276
  if (/\.(spec|test)\./.test(filePath)) return;
261
- files.push({ rel: relative(ROOT, filePath), src: readFileSync(filePath, "utf8") });
277
+ files.push({
278
+ abs: filePath,
279
+ rel: relative(ROOT, filePath),
280
+ src: readFileSync(filePath, "utf8"),
281
+ });
262
282
  };
263
283
  for (const dir of [PUBLIC_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, collect);
264
284
  sourceFileCache = files;
@@ -837,6 +857,332 @@ function checkTsconfigWorkspacePaths() {
837
857
  return violations;
838
858
  }
839
859
 
860
+ /**
861
+ * Metro (MPO-185): the condition names Metro matches, per platform, when
862
+ * `resolver.unstable_enablePackageExports` is on. `require`/`import` are
863
+ * Metro's own defaults; Expo's metro-config adds `react-native` for ios and
864
+ * android and `browser` for web. `default` always matches. Keys are matched
865
+ * in the ORDER the exports map declares them, first hit wins, and neither
866
+ * `source` nor `types` is ever in the set.
867
+ */
868
+ const METRO_PLATFORM_CONDITIONS = {
869
+ ios: new Set(["react-native", "import", "require", "default"]),
870
+ web: new Set(["browser", "import", "require", "default"]),
871
+ };
872
+ /** What Metro's file-based fallback will accept at `<pkg>/<subpath>`. */
873
+ const METRO_FALLBACK_EXTS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
874
+
875
+ /**
876
+ * First target Metro would pick from an exports-map entry for one condition
877
+ * set, walking nested condition objects in declaration order.
878
+ * @param {unknown} target
879
+ * @param {Set<string>} conditions
880
+ * @returns {string | null}
881
+ */
882
+ function metroExportTarget(target, conditions) {
883
+ if (typeof target === "string") return target;
884
+ if (Array.isArray(target)) {
885
+ for (const item of target) {
886
+ const hit = metroExportTarget(item, conditions);
887
+ if (hit) return hit;
888
+ }
889
+ return null;
890
+ }
891
+ if (!target || typeof target !== "object") return null;
892
+ for (const [key, value] of Object.entries(target)) {
893
+ if (!conditions.has(key)) continue;
894
+ const hit = metroExportTarget(value, conditions);
895
+ if (hit) return hit;
896
+ }
897
+ return null;
898
+ }
899
+
900
+ /**
901
+ * @param {unknown} target
902
+ * @returns {boolean} true when any condition in the entry is `source`
903
+ */
904
+ function declaresSourceCondition(target) {
905
+ if (!target || typeof target !== "object") return false;
906
+ if (Array.isArray(target)) return target.some(declaresSourceCondition);
907
+ return Object.entries(target).some(
908
+ ([key, value]) => key === "source" || declaresSourceCondition(value),
909
+ );
910
+ }
911
+
912
+ /**
913
+ * @param {string} pkgRoot
914
+ * @param {string} subpath an exports key such as `./seam`
915
+ */
916
+ function hasMetroFallback(pkgRoot, subpath) {
917
+ const base = join(pkgRoot, subpath.replace(/^\.\//, ""));
918
+ if (METRO_FALLBACK_EXTS.some((ext) => existsSync(`${base}.${ext}`))) return true;
919
+ if (existsSync(join(base, "package.json"))) return true;
920
+ return METRO_FALLBACK_EXTS.some((ext) => existsSync(join(base, `index.${ext}`)));
921
+ }
922
+
923
+ /**
924
+ * Metro subpath fallback (MPO-185).
925
+ *
926
+ * Metro reads the exports map, picks the first condition it recognises, and
927
+ * if that file is missing it does NOT reconsider `source` or `default`: it
928
+ * warns and retries as a plain file lookup for `<pkg>/<subpath>.*`. The
929
+ * packages under public/ and packages/ are consumed from source and never
930
+ * built, so a subpath whose Metro condition points into `dist/` fails the
931
+ * whole bundle the first time anything in a Metro graph imports it. The `.`
932
+ * export is exempt because its file fallback lands on the package root, where
933
+ * `main`/`module` point at src.
934
+ *
935
+ * A package is source-consumed when the entry or the package declares a
936
+ * `source` condition, or `main` points into src/. A genuinely built package
937
+ * (keycloak-js: no `source`, `main` in dist, `prepare` builds it) is skipped.
938
+ */
939
+ function checkMetroSubpathFallbacks() {
940
+ /** @type {string[]} */
941
+ const violations = [];
942
+ for (const dir of [PUBLIC_DIR, PACKAGES_DIR]) {
943
+ if (!existsSync(dir)) continue;
944
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
945
+ if (!entry.isDirectory()) continue;
946
+ const pkgRoot = join(dir, entry.name);
947
+ const pkgJsonPath = join(pkgRoot, "package.json");
948
+ if (!existsSync(pkgJsonPath)) continue;
949
+ let pkg;
950
+ try {
951
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
952
+ } catch {
953
+ continue;
954
+ }
955
+ const exportsMap = pkg?.exports;
956
+ if (!exportsMap || typeof exportsMap !== "object" || Array.isArray(exportsMap)) continue;
957
+ const packageFromSource =
958
+ typeof pkg.source === "string" ||
959
+ (typeof pkg.main === "string" && /^(\.\/)?src\//.test(pkg.main)) ||
960
+ declaresSourceCondition(exportsMap["."]);
961
+ for (const [subpath, target] of Object.entries(exportsMap)) {
962
+ if (!subpath.startsWith("./") || subpath.includes("*")) continue;
963
+ if (!packageFromSource && !declaresSourceCondition(target)) continue;
964
+ if (hasMetroFallback(pkgRoot, subpath)) continue;
965
+ for (const [platform, conditions] of Object.entries(METRO_PLATFORM_CONDITIONS)) {
966
+ const resolved = metroExportTarget(target, conditions);
967
+ if (!resolved || !resolved.startsWith("./dist/")) continue;
968
+ violations.push(
969
+ `${relative(ROOT, pkgJsonPath)} — "${subpath}" resolves to ${resolved} on ${platform}, which is unbuilt; add ${relative(ROOT, join(pkgRoot, `${subpath.replace(/^\.\//, "")}.ts`))} re-exporting the source entry`,
970
+ );
971
+ break;
972
+ }
973
+ }
974
+ }
975
+ }
976
+ return violations;
977
+ }
978
+
979
+ /**
980
+ * react-native-web's export list, read from the copy installed under ROOT.
981
+ *
982
+ * `dist/index.js` is the file the web target evaluates — it is RNW's `module`
983
+ * entry and what the storybook alias resolves `react-native` to — so its
984
+ * `export { … }` clauses ARE the vocabulary. Reading them is what keeps this
985
+ * check from drifting when RNW is upgraded; a hand-kept list would go stale
986
+ * the first time upstream adds or drops an export.
987
+ *
988
+ * @returns {Set<string> | undefined} undefined when RNW is not installed, which
989
+ * disables the check rather than failing a partial install.
990
+ */
991
+ function readReactNativeWebExports() {
992
+ const entry = join(ROOT, "node_modules", "react-native-web", "dist", "index.js");
993
+ if (!existsSync(entry)) return undefined;
994
+ /** @type {Set<string>} */
995
+ const names = new Set();
996
+ for (const clause of readFileSync(entry, "utf8").matchAll(/export\s*\{([^}]*)\}/g)) {
997
+ for (const specifier of clause[1].split(",")) {
998
+ const name = specifier
999
+ .trim()
1000
+ .split(/\s+as\s+/)
1001
+ .pop();
1002
+ if (name) names.add(name);
1003
+ }
1004
+ }
1005
+ return names.size ? names : undefined;
1006
+ }
1007
+
1008
+ /** Platform-suffixed files the web resolver never reaches. */
1009
+ const NATIVE_ONLY_FILE_RE = /\.(native|ios|android)\.(tsx|ts|jsx|js)$/;
1010
+
1011
+ /**
1012
+ * `import … from "react-native"`, including the multi-line brace form.
1013
+ *
1014
+ * The body may contain no quote and no `;`, which is what stops the non-greedy
1015
+ * match from swallowing an earlier `import … from "react"` and attributing
1016
+ * ITS names to react-native.
1017
+ */
1018
+ const REACT_NATIVE_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']react-native["']/g;
1019
+
1020
+ /** @param {string} absPath @param {string} dir */
1021
+ function isUnder(absPath, dir) {
1022
+ const rel = relative(dir, absPath);
1023
+ return rel !== "" && !rel.startsWith("..");
1024
+ }
1025
+
1026
+ /**
1027
+ * react-native named import with no react-native-web counterpart (MPO-199).
1028
+ *
1029
+ * Every web build aliases `react-native` to `react-native-web`
1030
+ * (apps/storybook/.storybook/main.ts, createStorybookViteConfig, the app vite
1031
+ * config). RNW does not implement all of react-native, so a NAMED import of
1032
+ * something it never exports is not a missing feature at runtime — it is a
1033
+ * module-evaluation SyntaxError, and one failing module takes the WHOLE bundle
1034
+ * with it. That is how `import { ActionSheetIOS } from "react-native"` in
1035
+ * Select (2b94d9eeb) made every story in the gallery render blank, not just
1036
+ * Select's.
1037
+ *
1038
+ * A `Platform.OS` guard inside the file cannot save it: the import fails
1039
+ * before any code runs. The two legal shapes are a namespace import, which
1040
+ * lands on `undefined` for a missing name (8c62c2a6f, the fix), and a
1041
+ * `.native.tsx` / `.ios.tsx` sibling the web resolver never reaches.
1042
+ *
1043
+ * Scope is public/ and features/ — the trees the preview bundle is built from.
1044
+ * Type-only specifiers are erased before any bundler sees them, so
1045
+ * `import type { GestureResponderEvent }` is fine and stays fine.
1046
+ */
1047
+ function checkReactNativeWebExports() {
1048
+ const exported = readReactNativeWebExports();
1049
+ if (!exported) return { ok: false, violations: /** @type {string[]} */ ([]) };
1050
+ /** @type {string[]} */
1051
+ const violations = [];
1052
+ for (const { abs, rel, src } of sourceFiles()) {
1053
+ if (!isUnder(abs, PUBLIC_DIR) && !isUnder(abs, FEATURES_DIR)) continue;
1054
+ if (NATIVE_ONLY_FILE_RE.test(abs)) continue;
1055
+ if (!src.includes("react-native")) continue;
1056
+ for (const statement of src.matchAll(REACT_NATIVE_IMPORT_RE)) {
1057
+ const clause = statement[1].trim();
1058
+ if (/^type\b/.test(clause)) continue;
1059
+ const named = clause.match(/\{([\s\S]*)\}/);
1060
+ // No brace group means a default or `* as ns` import, and a namespace
1061
+ // read of a missing name is `undefined`, never a SyntaxError.
1062
+ if (!named) continue;
1063
+ for (const specifier of named[1].split(",")) {
1064
+ const entry = specifier.trim();
1065
+ if (!entry || /^type\b/.test(entry)) continue;
1066
+ const name = entry.split(/\s+as\s+/)[0].trim();
1067
+ if (!name || exported.has(name)) continue;
1068
+ violations.push(
1069
+ `${rel}:${lineAt(src, statement.index ?? 0)} — ${name} (react-native-web exports no ${name})`,
1070
+ );
1071
+ }
1072
+ }
1073
+ }
1074
+ return { ok: true, violations };
1075
+ }
1076
+
1077
+ /**
1078
+ * Offsets in `src` that are live CODE — not inside a comment, a string or a
1079
+ * template literal. One left-to-right pass. Regex literals are treated as
1080
+ * code, and a `${…}` interpolation is treated as string, which can only ever
1081
+ * make this check miss something, never invent a violation.
1082
+ *
1083
+ * @param {string} src
1084
+ * @returns {Uint8Array} 1 at every code offset
1085
+ */
1086
+ function codeOffsets(src) {
1087
+ const mask = new Uint8Array(src.length);
1088
+ let i = 0;
1089
+ while (i < src.length) {
1090
+ const char = src[i];
1091
+ const next = src[i + 1];
1092
+ if (char === "/" && next === "/") {
1093
+ while (i < src.length && src[i] !== "\n") i++;
1094
+ continue;
1095
+ }
1096
+ if (char === "/" && next === "*") {
1097
+ i += 2;
1098
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
1099
+ i += 2;
1100
+ continue;
1101
+ }
1102
+ if (char === '"' || char === "'" || char === "`") {
1103
+ const quote = char;
1104
+ i++;
1105
+ while (i < src.length) {
1106
+ if (src[i] === "\\") {
1107
+ i += 2;
1108
+ continue;
1109
+ }
1110
+ if (src[i] === quote) {
1111
+ i++;
1112
+ break;
1113
+ }
1114
+ i++;
1115
+ }
1116
+ continue;
1117
+ }
1118
+ mask[i] = 1;
1119
+ i++;
1120
+ }
1121
+ return mask;
1122
+ }
1123
+
1124
+ const IMPORT_META_RE = /\bimport\s*\.\s*meta\b/g;
1125
+ const IMPORT_META_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|mts|cts)$/;
1126
+
1127
+ /**
1128
+ * A VALUE import of a node: builtin. `import type { … } from "node:fs"` is
1129
+ * erased, so it proves nothing about where the file runs and does not count.
1130
+ */
1131
+ const NODE_BUILTIN_IMPORT_RE =
1132
+ /(?:^|\n)\s*import\s+(?!type\s)[^;'"]*from\s*["']node:[^"']+["']|require\(\s*["']node:[^"']+["']\s*\)|import\(\s*["']node:[^"']+["']\s*\)/;
1133
+
1134
+ /**
1135
+ * `import.meta` in a public package's runtime source (MPO-204).
1136
+ *
1137
+ * MPO-192's ruling, generalised. `import.meta` is legal only in an ES module;
1138
+ * in a CLASSIC script it is a PARSE-time SyntaxError, so no `typeof
1139
+ * import.meta` guard and no try/catch around it ever gets to run — the file
1140
+ * simply never parses and takes its consumers down with it, silently. Two
1141
+ * bundles execute mpo as a classic script: the vxrn/rolldown native bundle for
1142
+ * Hermes, and any Metro bundle built for platform=web, which is exactly what
1143
+ * an Expo DOM component (`'use dom'`) is. A `.web.ts` twin is not an escape
1144
+ * either: One's web build and tamagui-build resolve `.web.ts` the way Metro
1145
+ * does, so a twin lands in the Vite build too.
1146
+ *
1147
+ * The replacement is a static `process.env.<KEY>` member read inside a
1148
+ * try/catch (public/platform/src/config/runtimeConfig.ts), or, for an SSR
1149
+ * flag, `isServer` from @multiplatform.one/platform, which is split at the
1150
+ * FILE level rather than by a bundler define.
1151
+ *
1152
+ * Exempt, by construction rather than by allowlist: a file that imports a
1153
+ * `node:` builtin as a value is Node-only build tooling
1154
+ * (@multiplatform.one/config's vite/vitest configs, the CLI,
1155
+ * @multiplatform.one/utils/dev) and could not run in a browser or on Hermes
1156
+ * whatever it did with import.meta. Specs are exempt for the same reason —
1157
+ * vitest runs them in Node and provides `import.meta.env` itself.
1158
+ */
1159
+ function checkImportMetaInPublicSource() {
1160
+ /** @type {string[]} */
1161
+ const violations = [];
1162
+ if (!existsSync(PUBLIC_DIR)) return violations;
1163
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1164
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1165
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
1166
+ if (!IMPORT_META_SCANNED_RE.test(filePath)) return;
1167
+ if (/\.(spec|test)\./.test(filePath)) return;
1168
+ const src = readFileSync(filePath, "utf8");
1169
+ if (!IMPORT_META_RE.test(src)) {
1170
+ IMPORT_META_RE.lastIndex = 0;
1171
+ return;
1172
+ }
1173
+ IMPORT_META_RE.lastIndex = 0;
1174
+ if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
1175
+ const mask = codeOffsets(src);
1176
+ for (const match of src.matchAll(IMPORT_META_RE)) {
1177
+ const index = match.index ?? 0;
1178
+ if (mask[index] !== 1) continue;
1179
+ violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)}`);
1180
+ }
1181
+ });
1182
+ }
1183
+ return violations;
1184
+ }
1185
+
840
1186
  /**
841
1187
  * Run structural convention checks over a consumer tree.
842
1188
  *
@@ -851,6 +1197,9 @@ export function runConventionChecks(options = {}) {
851
1197
  const storyShape = checkStoryFileShape();
852
1198
  const undeclaredDrawings = checkUndeclaredDrawings();
853
1199
  const tsconfigPaths = checkTsconfigWorkspacePaths();
1200
+ const metroFallbacks = checkMetroSubpathFallbacks();
1201
+ const rnWebExports = checkReactNativeWebExports();
1202
+ const importMeta = checkImportMetaInPublicSource();
854
1203
  const tokens = checkTransitionTokens();
855
1204
  const styleTokens = checkStyleTokens();
856
1205
  const themeNames = checkThemeNames();
@@ -926,6 +1275,33 @@ export function runConventionChecks(options = {}) {
926
1275
  console.error("");
927
1276
  }
928
1277
 
1278
+ if (metroFallbacks.length) {
1279
+ failed = true;
1280
+ console.error(
1281
+ "Convention (MPO-185 METRO SUBPATH FALLBACK): a subpath export in a source-consumed package must have a `<pkg>/<subpath>.ts` file re-exporting its source entry. Metro resolves the exports map to dist/, finds nothing (public/ is never built), and falls back to a FILE lookup at that path — it never reconsiders `source`, so the first import of the subpath fails the whole bundle. A genuinely built package carries no `source` condition and is skipped.\n",
1282
+ );
1283
+ for (const msg of metroFallbacks) console.error(` ${msg}`);
1284
+ console.error("");
1285
+ }
1286
+
1287
+ if (rnWebExports.violations.length) {
1288
+ failed = true;
1289
+ console.error(
1290
+ 'Convention (MPO-199 REACT-NATIVE-WEB EXPORT): a NAMED import from "react-native" must name something react-native-web exports. Every web build aliases react-native to react-native-web, so a name it does not export is a module-evaluation SyntaxError that takes the WHOLE bundle down — every story in the gallery, not just the importing component — and a Platform.OS guard cannot help, because the import fails before any code runs. Read it off a namespace import (`import * as ReactNative from "react-native"`) or move the call into a .native.tsx sibling. `import type { … }` is erased and never counts.\n',
1291
+ );
1292
+ for (const msg of rnWebExports.violations) console.error(` ${msg}`);
1293
+ console.error("");
1294
+ }
1295
+
1296
+ if (importMeta.length) {
1297
+ failed = true;
1298
+ console.error(
1299
+ "Convention (MPO-204 NO import.meta IN public/*/src): `import.meta` is legal only in an ES module. In a CLASSIC script it is a PARSE-time SyntaxError, so no `typeof import.meta` guard and no try/catch around it ever runs — the file never parses and takes its consumers with it, silently. Two bundles execute mpo as a classic script: the vxrn/rolldown native bundle for Hermes, and any Metro bundle for platform=web, which is what an Expo DOM component (`'use dom'`) is. Read a static `process.env.<KEY>` member expression inside try/catch instead (public/platform/src/config/runtimeConfig.ts), or take an SSR flag from `isServer` in @multiplatform.one/platform. A .web.ts twin is not an escape — One's web build and tamagui-build resolve it like Metro. Node-only build tooling (a file importing a `node:` builtin) and specs are exempt.\n",
1300
+ );
1301
+ for (const msg of importMeta) console.error(` ${msg}`);
1302
+ console.error("");
1303
+ }
1304
+
929
1305
  if (tokens.violations.length) {
930
1306
  failed = true;
931
1307
  console.error(
@@ -966,6 +1342,7 @@ export function runConventionChecks(options = {}) {
966
1342
  ["style token", styleTokens.ok],
967
1343
  ["theme name", themeNames.ok],
968
1344
  ["intent", intents.ok],
1345
+ ["react-native-web export", rnWebExports.ok],
969
1346
  ]) {
970
1347
  if (!ok) {
971
1348
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.7.0",
3
+ "version": "7.7.1",
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.7.0"
105
+ "@multiplatform.one/utils": "7.7.1"
106
106
  },
107
107
  "devDependencies": {
108
108
  "tsdown": "^0.21.10",
package/src/lint.spec.ts CHANGED
@@ -239,3 +239,315 @@ describe("undeclared drawing file (MPO-17)", () => {
239
239
  expect(code).toBe(0);
240
240
  });
241
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",
package/src/vite.ts CHANGED
@@ -443,6 +443,20 @@ export function createViteConfig(options: CreateViteConfigOptions = {}): UserCon
443
443
  "process.env.VITE_FRAPPE_ENABLED": JSON.stringify(
444
444
  process.env.VITE_FRAPPE_ENABLED === "true" ? "true" : "false",
445
445
  ),
446
+ // The public-config bake. @multiplatform.one/platform's runtimeConfig
447
+ // reads exactly these two static expressions (it carries no import.meta,
448
+ // because Expo DOM components run it as a classic script, MPO-192), and
449
+ // a static `process.env.<KEY>` is what Vite, vxrn/One and Metro all
450
+ // inline. Absent when no public keys were declared, so a build without
451
+ // them leaves the expressions alone and the reader falls back to {}.
452
+ ...(publicConfigKeys.length > 0
453
+ ? {
454
+ "process.env.VITE_MP_CONFIG": JSON.stringify(process.env.VITE_MP_CONFIG ?? ""),
455
+ "process.env.VITE_MP_PUBLIC_CONFIG_KEYS": JSON.stringify(
456
+ process.env.VITE_MP_PUBLIC_CONFIG_KEYS ?? "",
457
+ ),
458
+ }
459
+ : {}),
446
460
  },
447
461
  ssr: ssr || defaultSsr,
448
462
  resolve: {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Vite `resolve.alias` entries pinning each context singleton to one directory.
3
+ *
4
+ * Alias keys match the whole specifier or a `key + "/"` prefix, so subpath
5
+ * imports follow the same copy instead of resolving independently. Entries are
6
+ * omitted when the package is not installed, so a consumer that does not use
7
+ * cookies is unaffected.
8
+ *
9
+ * @param root Directory whose `node_modules` the packages resolve from.
10
+ */
11
+ export declare function singletonDepAliases(root?: string): Record<string, string>;
12
+ //# sourceMappingURL=singletonDepAliases.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"singletonDepAliases.d.ts","sourceRoot":"","sources":["../src/singletonDepAliases.ts"],"names":[],"mappings":"AA0DA;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,MAAsB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxF"}
@@ -1 +1 @@
1
- {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../src/storybook.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAU,UAAU,EAAE,MAAM,MAAM,CAAC;AAK/C,MAAM,WAAW,gCAAgC;IAC/C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEjC;;OAEG;IACH,OAAO,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;IAEhC;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,GAAE,gCAAqC,GAC7C,UAAU,CAwKZ"}
1
+ {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../src/storybook.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAU,UAAU,EAAE,MAAM,MAAM,CAAC;AAM/C,MAAM,WAAW,gCAAgC;IAC/C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEjC;;OAEG;IACH,OAAO,CAAC,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC;IAEhC;;OAEG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,GAAE,gCAAqC,GAC7C,UAAU,CAuLZ"}
@@ -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,CA4SlF;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,CA0TlF;AAED,OAAO,EACL,0BAA0B,EAC1B,8BAA8B,GAC/B,MAAM,8BAA8B,CAAC"}