@multiplatform.one/config 7.7.0 → 7.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +2 -2
- package/lib/{storybook-BnfZmzIL.js → storybook-BuO1e8Sb.js} +98 -1
- package/lib/storybook.js +1 -1
- package/lib/{vite-Rv5b8Wxp.js → vite-uwqTTN8M.js} +7 -1
- package/lib/vite.js +1 -1
- package/lint.mjs +482 -2
- package/package.json +2 -2
- package/src/lint.spec.ts +312 -0
- package/src/singletonDepAliases.spec.ts +83 -0
- package/src/singletonDepAliases.ts +105 -0
- package/src/storybook.ts +16 -0
- package/src/vite.ts +14 -0
- package/types/singletonDepAliases.d.ts +12 -0
- package/types/singletonDepAliases.d.ts.map +1 -0
- package/types/storybook.d.ts.map +1 -1
- package/types/vite.d.ts.map +1 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as createViteConfig } from "./vite-
|
|
2
|
-
import { t as createStorybookViteConfig } from "./storybook-
|
|
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
|
@@ -190,7 +190,13 @@ function createViteConfig(options = {}) {
|
|
|
190
190
|
return {
|
|
191
191
|
css: { modules: { localsConvention: "camelCase" } },
|
|
192
192
|
build: { chunkSizeWarningLimit: 600 },
|
|
193
|
-
define: {
|
|
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-
|
|
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,33 @@
|
|
|
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 a `.react-flow__` SELECTOR outside the token bridge (MPO-191) —
|
|
41
|
+
* React Flow paints through `var(--xy-thing, var(--xy-thing-default))`, so
|
|
42
|
+
* every colour, width and shadow is settable without touching its
|
|
43
|
+
* stylesheet. A hand-written `.react-flow__` rule anywhere else forks that
|
|
44
|
+
* paint: it wins or loses on specificity rather than on the knob, and the
|
|
45
|
+
* next library upgrade silently changes which. The whole point of the
|
|
46
|
+
* bridge is that a designer never touches React Flow CSS — including us —
|
|
47
|
+
* so the rule is mechanical. `theme/graphVars.ts` (the map) and the
|
|
48
|
+
* package's own `css/` tree (outside `src`) are the two places it may live.
|
|
49
|
+
* Comments and specs are exempt; strings are not, because a hand-written
|
|
50
|
+
* rule lives in a template literal.
|
|
51
|
+
* 10. Fail on an `import.meta` TOKEN in public/<pkg>/src (MPO-204) — in a
|
|
52
|
+
* classic script it is a PARSE-time SyntaxError, and two bundles execute
|
|
53
|
+
* mpo as one (the vxrn/rolldown Hermes bundle, and any Metro bundle for
|
|
54
|
+
* platform=web, i.e. an Expo DOM component). Node-only build tooling is
|
|
55
|
+
* exempt by construction: a file that imports a `node:` builtin cannot run
|
|
56
|
+
* in a browser or on Hermes anyway.
|
|
30
57
|
*
|
|
31
58
|
* Usage:
|
|
32
59
|
* import { runConventionChecks } from "@multiplatform.one/config/lint";
|
|
@@ -253,12 +280,16 @@ function keysOfBlock(body, indent) {
|
|
|
253
280
|
let sourceFileCache;
|
|
254
281
|
function sourceFiles() {
|
|
255
282
|
if (sourceFileCache) return sourceFileCache;
|
|
256
|
-
/** @type {{ rel: string, src: string }[]} */
|
|
283
|
+
/** @type {{ abs: string, rel: string, src: string }[]} */
|
|
257
284
|
const files = [];
|
|
258
285
|
const collect = (filePath) => {
|
|
259
286
|
if (!/\.(tsx|ts|jsx|js)$/.test(filePath)) return;
|
|
260
287
|
if (/\.(spec|test)\./.test(filePath)) return;
|
|
261
|
-
files.push({
|
|
288
|
+
files.push({
|
|
289
|
+
abs: filePath,
|
|
290
|
+
rel: relative(ROOT, filePath),
|
|
291
|
+
src: readFileSync(filePath, "utf8"),
|
|
292
|
+
});
|
|
262
293
|
};
|
|
263
294
|
for (const dir of [PUBLIC_DIR, APPS_DIR, FEATURES_DIR]) walkFiles(dir, collect);
|
|
264
295
|
sourceFileCache = files;
|
|
@@ -837,6 +868,414 @@ function checkTsconfigWorkspacePaths() {
|
|
|
837
868
|
return violations;
|
|
838
869
|
}
|
|
839
870
|
|
|
871
|
+
/**
|
|
872
|
+
* Metro (MPO-185): the condition names Metro matches, per platform, when
|
|
873
|
+
* `resolver.unstable_enablePackageExports` is on. `require`/`import` are
|
|
874
|
+
* Metro's own defaults; Expo's metro-config adds `react-native` for ios and
|
|
875
|
+
* android and `browser` for web. `default` always matches. Keys are matched
|
|
876
|
+
* in the ORDER the exports map declares them, first hit wins, and neither
|
|
877
|
+
* `source` nor `types` is ever in the set.
|
|
878
|
+
*/
|
|
879
|
+
const METRO_PLATFORM_CONDITIONS = {
|
|
880
|
+
ios: new Set(["react-native", "import", "require", "default"]),
|
|
881
|
+
web: new Set(["browser", "import", "require", "default"]),
|
|
882
|
+
};
|
|
883
|
+
/** What Metro's file-based fallback will accept at `<pkg>/<subpath>`. */
|
|
884
|
+
const METRO_FALLBACK_EXTS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* First target Metro would pick from an exports-map entry for one condition
|
|
888
|
+
* set, walking nested condition objects in declaration order.
|
|
889
|
+
* @param {unknown} target
|
|
890
|
+
* @param {Set<string>} conditions
|
|
891
|
+
* @returns {string | null}
|
|
892
|
+
*/
|
|
893
|
+
function metroExportTarget(target, conditions) {
|
|
894
|
+
if (typeof target === "string") return target;
|
|
895
|
+
if (Array.isArray(target)) {
|
|
896
|
+
for (const item of target) {
|
|
897
|
+
const hit = metroExportTarget(item, conditions);
|
|
898
|
+
if (hit) return hit;
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
if (!target || typeof target !== "object") return null;
|
|
903
|
+
for (const [key, value] of Object.entries(target)) {
|
|
904
|
+
if (!conditions.has(key)) continue;
|
|
905
|
+
const hit = metroExportTarget(value, conditions);
|
|
906
|
+
if (hit) return hit;
|
|
907
|
+
}
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* @param {unknown} target
|
|
913
|
+
* @returns {boolean} true when any condition in the entry is `source`
|
|
914
|
+
*/
|
|
915
|
+
function declaresSourceCondition(target) {
|
|
916
|
+
if (!target || typeof target !== "object") return false;
|
|
917
|
+
if (Array.isArray(target)) return target.some(declaresSourceCondition);
|
|
918
|
+
return Object.entries(target).some(
|
|
919
|
+
([key, value]) => key === "source" || declaresSourceCondition(value),
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* @param {string} pkgRoot
|
|
925
|
+
* @param {string} subpath an exports key such as `./seam`
|
|
926
|
+
*/
|
|
927
|
+
function hasMetroFallback(pkgRoot, subpath) {
|
|
928
|
+
const base = join(pkgRoot, subpath.replace(/^\.\//, ""));
|
|
929
|
+
if (METRO_FALLBACK_EXTS.some((ext) => existsSync(`${base}.${ext}`))) return true;
|
|
930
|
+
if (existsSync(join(base, "package.json"))) return true;
|
|
931
|
+
return METRO_FALLBACK_EXTS.some((ext) => existsSync(join(base, `index.${ext}`)));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Metro subpath fallback (MPO-185).
|
|
936
|
+
*
|
|
937
|
+
* Metro reads the exports map, picks the first condition it recognises, and
|
|
938
|
+
* if that file is missing it does NOT reconsider `source` or `default`: it
|
|
939
|
+
* warns and retries as a plain file lookup for `<pkg>/<subpath>.*`. The
|
|
940
|
+
* packages under public/ and packages/ are consumed from source and never
|
|
941
|
+
* built, so a subpath whose Metro condition points into `dist/` fails the
|
|
942
|
+
* whole bundle the first time anything in a Metro graph imports it. The `.`
|
|
943
|
+
* export is exempt because its file fallback lands on the package root, where
|
|
944
|
+
* `main`/`module` point at src.
|
|
945
|
+
*
|
|
946
|
+
* A package is source-consumed when the entry or the package declares a
|
|
947
|
+
* `source` condition, or `main` points into src/. A genuinely built package
|
|
948
|
+
* (keycloak-js: no `source`, `main` in dist, `prepare` builds it) is skipped.
|
|
949
|
+
*/
|
|
950
|
+
function checkMetroSubpathFallbacks() {
|
|
951
|
+
/** @type {string[]} */
|
|
952
|
+
const violations = [];
|
|
953
|
+
for (const dir of [PUBLIC_DIR, PACKAGES_DIR]) {
|
|
954
|
+
if (!existsSync(dir)) continue;
|
|
955
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
956
|
+
if (!entry.isDirectory()) continue;
|
|
957
|
+
const pkgRoot = join(dir, entry.name);
|
|
958
|
+
const pkgJsonPath = join(pkgRoot, "package.json");
|
|
959
|
+
if (!existsSync(pkgJsonPath)) continue;
|
|
960
|
+
let pkg;
|
|
961
|
+
try {
|
|
962
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
963
|
+
} catch {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const exportsMap = pkg?.exports;
|
|
967
|
+
if (!exportsMap || typeof exportsMap !== "object" || Array.isArray(exportsMap)) continue;
|
|
968
|
+
const packageFromSource =
|
|
969
|
+
typeof pkg.source === "string" ||
|
|
970
|
+
(typeof pkg.main === "string" && /^(\.\/)?src\//.test(pkg.main)) ||
|
|
971
|
+
declaresSourceCondition(exportsMap["."]);
|
|
972
|
+
for (const [subpath, target] of Object.entries(exportsMap)) {
|
|
973
|
+
if (!subpath.startsWith("./") || subpath.includes("*")) continue;
|
|
974
|
+
if (!packageFromSource && !declaresSourceCondition(target)) continue;
|
|
975
|
+
if (hasMetroFallback(pkgRoot, subpath)) continue;
|
|
976
|
+
for (const [platform, conditions] of Object.entries(METRO_PLATFORM_CONDITIONS)) {
|
|
977
|
+
const resolved = metroExportTarget(target, conditions);
|
|
978
|
+
if (!resolved || !resolved.startsWith("./dist/")) continue;
|
|
979
|
+
violations.push(
|
|
980
|
+
`${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`,
|
|
981
|
+
);
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return violations;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* react-native-web's export list, read from the copy installed under ROOT.
|
|
992
|
+
*
|
|
993
|
+
* `dist/index.js` is the file the web target evaluates — it is RNW's `module`
|
|
994
|
+
* entry and what the storybook alias resolves `react-native` to — so its
|
|
995
|
+
* `export { … }` clauses ARE the vocabulary. Reading them is what keeps this
|
|
996
|
+
* check from drifting when RNW is upgraded; a hand-kept list would go stale
|
|
997
|
+
* the first time upstream adds or drops an export.
|
|
998
|
+
*
|
|
999
|
+
* @returns {Set<string> | undefined} undefined when RNW is not installed, which
|
|
1000
|
+
* disables the check rather than failing a partial install.
|
|
1001
|
+
*/
|
|
1002
|
+
function readReactNativeWebExports() {
|
|
1003
|
+
const entry = join(ROOT, "node_modules", "react-native-web", "dist", "index.js");
|
|
1004
|
+
if (!existsSync(entry)) return undefined;
|
|
1005
|
+
/** @type {Set<string>} */
|
|
1006
|
+
const names = new Set();
|
|
1007
|
+
for (const clause of readFileSync(entry, "utf8").matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
1008
|
+
for (const specifier of clause[1].split(",")) {
|
|
1009
|
+
const name = specifier
|
|
1010
|
+
.trim()
|
|
1011
|
+
.split(/\s+as\s+/)
|
|
1012
|
+
.pop();
|
|
1013
|
+
if (name) names.add(name);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return names.size ? names : undefined;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Platform-suffixed files the web resolver never reaches. */
|
|
1020
|
+
const NATIVE_ONLY_FILE_RE = /\.(native|ios|android)\.(tsx|ts|jsx|js)$/;
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* `import … from "react-native"`, including the multi-line brace form.
|
|
1024
|
+
*
|
|
1025
|
+
* The body may contain no quote and no `;`, which is what stops the non-greedy
|
|
1026
|
+
* match from swallowing an earlier `import … from "react"` and attributing
|
|
1027
|
+
* ITS names to react-native.
|
|
1028
|
+
*/
|
|
1029
|
+
const REACT_NATIVE_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']react-native["']/g;
|
|
1030
|
+
|
|
1031
|
+
/** @param {string} absPath @param {string} dir */
|
|
1032
|
+
function isUnder(absPath, dir) {
|
|
1033
|
+
const rel = relative(dir, absPath);
|
|
1034
|
+
return rel !== "" && !rel.startsWith("..");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* react-native named import with no react-native-web counterpart (MPO-199).
|
|
1039
|
+
*
|
|
1040
|
+
* Every web build aliases `react-native` to `react-native-web`
|
|
1041
|
+
* (apps/storybook/.storybook/main.ts, createStorybookViteConfig, the app vite
|
|
1042
|
+
* config). RNW does not implement all of react-native, so a NAMED import of
|
|
1043
|
+
* something it never exports is not a missing feature at runtime — it is a
|
|
1044
|
+
* module-evaluation SyntaxError, and one failing module takes the WHOLE bundle
|
|
1045
|
+
* with it. That is how `import { ActionSheetIOS } from "react-native"` in
|
|
1046
|
+
* Select (2b94d9eeb) made every story in the gallery render blank, not just
|
|
1047
|
+
* Select's.
|
|
1048
|
+
*
|
|
1049
|
+
* A `Platform.OS` guard inside the file cannot save it: the import fails
|
|
1050
|
+
* before any code runs. The two legal shapes are a namespace import, which
|
|
1051
|
+
* lands on `undefined` for a missing name (8c62c2a6f, the fix), and a
|
|
1052
|
+
* `.native.tsx` / `.ios.tsx` sibling the web resolver never reaches.
|
|
1053
|
+
*
|
|
1054
|
+
* Scope is public/ and features/ — the trees the preview bundle is built from.
|
|
1055
|
+
* Type-only specifiers are erased before any bundler sees them, so
|
|
1056
|
+
* `import type { GestureResponderEvent }` is fine and stays fine.
|
|
1057
|
+
*/
|
|
1058
|
+
function checkReactNativeWebExports() {
|
|
1059
|
+
const exported = readReactNativeWebExports();
|
|
1060
|
+
if (!exported) return { ok: false, violations: /** @type {string[]} */ ([]) };
|
|
1061
|
+
/** @type {string[]} */
|
|
1062
|
+
const violations = [];
|
|
1063
|
+
for (const { abs, rel, src } of sourceFiles()) {
|
|
1064
|
+
if (!isUnder(abs, PUBLIC_DIR) && !isUnder(abs, FEATURES_DIR)) continue;
|
|
1065
|
+
if (NATIVE_ONLY_FILE_RE.test(abs)) continue;
|
|
1066
|
+
if (!src.includes("react-native")) continue;
|
|
1067
|
+
for (const statement of src.matchAll(REACT_NATIVE_IMPORT_RE)) {
|
|
1068
|
+
const clause = statement[1].trim();
|
|
1069
|
+
if (/^type\b/.test(clause)) continue;
|
|
1070
|
+
const named = clause.match(/\{([\s\S]*)\}/);
|
|
1071
|
+
// No brace group means a default or `* as ns` import, and a namespace
|
|
1072
|
+
// read of a missing name is `undefined`, never a SyntaxError.
|
|
1073
|
+
if (!named) continue;
|
|
1074
|
+
for (const specifier of named[1].split(",")) {
|
|
1075
|
+
const entry = specifier.trim();
|
|
1076
|
+
if (!entry || /^type\b/.test(entry)) continue;
|
|
1077
|
+
const name = entry.split(/\s+as\s+/)[0].trim();
|
|
1078
|
+
if (!name || exported.has(name)) continue;
|
|
1079
|
+
violations.push(
|
|
1080
|
+
`${rel}:${lineAt(src, statement.index ?? 0)} — ${name} (react-native-web exports no ${name})`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return { ok: true, violations };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Offsets in `src` that are live CODE — not inside a comment, a string or a
|
|
1090
|
+
* template literal. One left-to-right pass. Regex literals are treated as
|
|
1091
|
+
* code, and a `${…}` interpolation is treated as string, which can only ever
|
|
1092
|
+
* make this check miss something, never invent a violation.
|
|
1093
|
+
*
|
|
1094
|
+
* @param {string} src
|
|
1095
|
+
* @returns {Uint8Array} 1 at every code offset
|
|
1096
|
+
*/
|
|
1097
|
+
function codeOffsets(src) {
|
|
1098
|
+
const mask = new Uint8Array(src.length);
|
|
1099
|
+
let i = 0;
|
|
1100
|
+
while (i < src.length) {
|
|
1101
|
+
const char = src[i];
|
|
1102
|
+
const next = src[i + 1];
|
|
1103
|
+
if (char === "/" && next === "/") {
|
|
1104
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
if (char === "/" && next === "*") {
|
|
1108
|
+
i += 2;
|
|
1109
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
1110
|
+
i += 2;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1114
|
+
const quote = char;
|
|
1115
|
+
i++;
|
|
1116
|
+
while (i < src.length) {
|
|
1117
|
+
if (src[i] === "\\") {
|
|
1118
|
+
i += 2;
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (src[i] === quote) {
|
|
1122
|
+
i++;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
i++;
|
|
1126
|
+
}
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
mask[i] = 1;
|
|
1130
|
+
i++;
|
|
1131
|
+
}
|
|
1132
|
+
return mask;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const IMPORT_META_RE = /\bimport\s*\.\s*meta\b/g;
|
|
1136
|
+
const IMPORT_META_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|mts|cts)$/;
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* A VALUE import of a node: builtin. `import type { … } from "node:fs"` is
|
|
1140
|
+
* erased, so it proves nothing about where the file runs and does not count.
|
|
1141
|
+
*/
|
|
1142
|
+
const NODE_BUILTIN_IMPORT_RE =
|
|
1143
|
+
/(?:^|\n)\s*import\s+(?!type\s)[^;'"]*from\s*["']node:[^"']+["']|require\(\s*["']node:[^"']+["']\s*\)|import\(\s*["']node:[^"']+["']\s*\)/;
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* `import.meta` in a public package's runtime source (MPO-204).
|
|
1147
|
+
*
|
|
1148
|
+
* MPO-192's ruling, generalised. `import.meta` is legal only in an ES module;
|
|
1149
|
+
* in a CLASSIC script it is a PARSE-time SyntaxError, so no `typeof
|
|
1150
|
+
* import.meta` guard and no try/catch around it ever gets to run — the file
|
|
1151
|
+
* simply never parses and takes its consumers down with it, silently. Two
|
|
1152
|
+
* bundles execute mpo as a classic script: the vxrn/rolldown native bundle for
|
|
1153
|
+
* Hermes, and any Metro bundle built for platform=web, which is exactly what
|
|
1154
|
+
* an Expo DOM component (`'use dom'`) is. A `.web.ts` twin is not an escape
|
|
1155
|
+
* either: One's web build and tamagui-build resolve `.web.ts` the way Metro
|
|
1156
|
+
* does, so a twin lands in the Vite build too.
|
|
1157
|
+
*
|
|
1158
|
+
* The replacement is a static `process.env.<KEY>` member read inside a
|
|
1159
|
+
* try/catch (public/platform/src/config/runtimeConfig.ts), or, for an SSR
|
|
1160
|
+
* flag, `isServer` from @multiplatform.one/platform, which is split at the
|
|
1161
|
+
* FILE level rather than by a bundler define.
|
|
1162
|
+
*
|
|
1163
|
+
* Exempt, by construction rather than by allowlist: a file that imports a
|
|
1164
|
+
* `node:` builtin as a value is Node-only build tooling
|
|
1165
|
+
* (@multiplatform.one/config's vite/vitest configs, the CLI,
|
|
1166
|
+
* @multiplatform.one/utils/dev) and could not run in a browser or on Hermes
|
|
1167
|
+
* whatever it did with import.meta. Specs are exempt for the same reason —
|
|
1168
|
+
* vitest runs them in Node and provides `import.meta.env` itself.
|
|
1169
|
+
*/
|
|
1170
|
+
function checkImportMetaInPublicSource() {
|
|
1171
|
+
/** @type {string[]} */
|
|
1172
|
+
const violations = [];
|
|
1173
|
+
if (!existsSync(PUBLIC_DIR)) return violations;
|
|
1174
|
+
for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
|
|
1175
|
+
if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
|
|
1176
|
+
walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
|
|
1177
|
+
if (!IMPORT_META_SCANNED_RE.test(filePath)) return;
|
|
1178
|
+
if (/\.(spec|test)\./.test(filePath)) return;
|
|
1179
|
+
const src = readFileSync(filePath, "utf8");
|
|
1180
|
+
if (!IMPORT_META_RE.test(src)) {
|
|
1181
|
+
IMPORT_META_RE.lastIndex = 0;
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
IMPORT_META_RE.lastIndex = 0;
|
|
1185
|
+
if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
|
|
1186
|
+
const mask = codeOffsets(src);
|
|
1187
|
+
for (const match of src.matchAll(IMPORT_META_RE)) {
|
|
1188
|
+
const index = match.index ?? 0;
|
|
1189
|
+
if (mask[index] !== 1) continue;
|
|
1190
|
+
violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)}`);
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
return violations;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ── React Flow selectors outside the token bridge (MPO-191) ───────────────
|
|
1198
|
+
|
|
1199
|
+
const REACT_FLOW_SELECTOR_RE = /\.react-flow__[\w-]+/g;
|
|
1200
|
+
/**
|
|
1201
|
+
* The two files a `.react-flow__` string may live in, both DECLARED boundaries.
|
|
1202
|
+
*
|
|
1203
|
+
* `theme/graphVars.ts` owns the paint: every React Flow surface is set through
|
|
1204
|
+
* its own `--xy-*` variable there, never through a rule.
|
|
1205
|
+
*
|
|
1206
|
+
* `react-flow/xy.ts` owns the @xyflow DOM contract — it is already the one file
|
|
1207
|
+
* allowed to import the library at all (oxlint `no-restricted-imports`), and a
|
|
1208
|
+
* class NAME is part of that contract the same way an exported symbol is. The
|
|
1209
|
+
* long press has to find the pane to listen on it, which is a DOM lookup and
|
|
1210
|
+
* not a paint rule; putting the string at the call site would mean a library
|
|
1211
|
+
* rename lands in a `querySelector` that returns null in silence.
|
|
1212
|
+
*/
|
|
1213
|
+
const GRAPH_VARS_EXEMPT = ["theme/graphVars.ts", "react-flow/xy.ts"];
|
|
1214
|
+
const REACT_FLOW_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|css)$/;
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Offsets that are NOT inside a comment.
|
|
1218
|
+
*
|
|
1219
|
+
* `codeOffsets` masks strings as well, which is the opposite of what this needs:
|
|
1220
|
+
* a hand-written React Flow rule lives in a template literal or a `className`,
|
|
1221
|
+
* so strings are exactly the interesting part. Comments are not — the map's own
|
|
1222
|
+
* allowlist explains itself by naming `.react-flow__node-input`, and that
|
|
1223
|
+
* explanation must not be the thing that fails the build.
|
|
1224
|
+
* @param {string} src
|
|
1225
|
+
*/
|
|
1226
|
+
function nonCommentOffsets(src) {
|
|
1227
|
+
const mask = new Uint8Array(src.length).fill(1);
|
|
1228
|
+
let i = 0;
|
|
1229
|
+
while (i < src.length) {
|
|
1230
|
+
const char = src[i];
|
|
1231
|
+
const next = src[i + 1];
|
|
1232
|
+
if (char === "/" && next === "/") {
|
|
1233
|
+
while (i < src.length && src[i] !== "\n") mask[i++] = 0;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (char === "/" && next === "*") {
|
|
1237
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) mask[i++] = 0;
|
|
1238
|
+
mask[i] = 0;
|
|
1239
|
+
mask[i + 1] = 0;
|
|
1240
|
+
i += 2;
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
i++;
|
|
1244
|
+
}
|
|
1245
|
+
return mask;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* MPO-191: a `.react-flow__` selector under public/<pkg>/src, outside the one
|
|
1250
|
+
* file that owns the mapping.
|
|
1251
|
+
*/
|
|
1252
|
+
function checkGraphVarsCoverage() {
|
|
1253
|
+
/** @type {string[]} */
|
|
1254
|
+
const violations = [];
|
|
1255
|
+
if (!existsSync(PUBLIC_DIR)) return violations;
|
|
1256
|
+
for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
|
|
1257
|
+
if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
|
|
1258
|
+
walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
|
|
1259
|
+
if (!REACT_FLOW_SCANNED_RE.test(filePath)) return;
|
|
1260
|
+
const posix = filePath.replace(/\\/g, "/");
|
|
1261
|
+
if (GRAPH_VARS_EXEMPT.some((exempt) => posix.endsWith(exempt))) return;
|
|
1262
|
+
// Specs are exempt for the reason check 10 exempts them: a spec that
|
|
1263
|
+
// asserts WHICH classes a variable binds to has to name those classes,
|
|
1264
|
+
// and it ships no paint. Nothing else is exempt, including stories.
|
|
1265
|
+
if (/\.(spec|test)\./.test(filePath)) return;
|
|
1266
|
+
const src = readFileSync(filePath, "utf8");
|
|
1267
|
+
if (!src.includes(".react-flow__")) return;
|
|
1268
|
+
const mask = nonCommentOffsets(src);
|
|
1269
|
+
for (const match of src.matchAll(REACT_FLOW_SELECTOR_RE)) {
|
|
1270
|
+
const index = match.index ?? 0;
|
|
1271
|
+
if (mask[index] !== 1) continue;
|
|
1272
|
+
violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)} — ${match[0]}`);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return violations;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
840
1279
|
/**
|
|
841
1280
|
* Run structural convention checks over a consumer tree.
|
|
842
1281
|
*
|
|
@@ -851,6 +1290,10 @@ export function runConventionChecks(options = {}) {
|
|
|
851
1290
|
const storyShape = checkStoryFileShape();
|
|
852
1291
|
const undeclaredDrawings = checkUndeclaredDrawings();
|
|
853
1292
|
const tsconfigPaths = checkTsconfigWorkspacePaths();
|
|
1293
|
+
const metroFallbacks = checkMetroSubpathFallbacks();
|
|
1294
|
+
const rnWebExports = checkReactNativeWebExports();
|
|
1295
|
+
const importMeta = checkImportMetaInPublicSource();
|
|
1296
|
+
const graphVars = checkGraphVarsCoverage();
|
|
854
1297
|
const tokens = checkTransitionTokens();
|
|
855
1298
|
const styleTokens = checkStyleTokens();
|
|
856
1299
|
const themeNames = checkThemeNames();
|
|
@@ -926,6 +1369,42 @@ export function runConventionChecks(options = {}) {
|
|
|
926
1369
|
console.error("");
|
|
927
1370
|
}
|
|
928
1371
|
|
|
1372
|
+
if (metroFallbacks.length) {
|
|
1373
|
+
failed = true;
|
|
1374
|
+
console.error(
|
|
1375
|
+
"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",
|
|
1376
|
+
);
|
|
1377
|
+
for (const msg of metroFallbacks) console.error(` ${msg}`);
|
|
1378
|
+
console.error("");
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (rnWebExports.violations.length) {
|
|
1382
|
+
failed = true;
|
|
1383
|
+
console.error(
|
|
1384
|
+
'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',
|
|
1385
|
+
);
|
|
1386
|
+
for (const msg of rnWebExports.violations) console.error(` ${msg}`);
|
|
1387
|
+
console.error("");
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if (graphVars.length) {
|
|
1391
|
+
failed = true;
|
|
1392
|
+
console.error(
|
|
1393
|
+
"Convention (MPO-191 GRAPH VARS COVERAGE): a `.react-flow__` selector belongs in the token bridge, nowhere else. React Flow reads every paint through `var(--xy-thing, var(--xy-thing-default))`, so the whole surface is settable without touching its stylesheet; a rule written anywhere else wins or loses on specificity instead of on the knob, and the next @xyflow/react upgrade silently changes which. Set the variable in `theme/graphVars.ts`, or put the rule in the package's own `css/` tree (outside `src`) where a reviewer reads it whole. A DOM LOOKUP is the one other legitimate use, and it belongs in `react-flow/xy.ts` beside the rest of the @xyflow contract. A mention inside a comment is fine and is not flagged.\n",
|
|
1394
|
+
);
|
|
1395
|
+
for (const msg of graphVars) console.error(` ${msg}`);
|
|
1396
|
+
console.error("");
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (importMeta.length) {
|
|
1400
|
+
failed = true;
|
|
1401
|
+
console.error(
|
|
1402
|
+
"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",
|
|
1403
|
+
);
|
|
1404
|
+
for (const msg of importMeta) console.error(` ${msg}`);
|
|
1405
|
+
console.error("");
|
|
1406
|
+
}
|
|
1407
|
+
|
|
929
1408
|
if (tokens.violations.length) {
|
|
930
1409
|
failed = true;
|
|
931
1410
|
console.error(
|
|
@@ -966,6 +1445,7 @@ export function runConventionChecks(options = {}) {
|
|
|
966
1445
|
["style token", styleTokens.ok],
|
|
967
1446
|
["theme name", themeNames.ok],
|
|
968
1447
|
["intent", intents.ok],
|
|
1448
|
+
["react-native-web export", rnWebExports.ok],
|
|
969
1449
|
]) {
|
|
970
1450
|
if (!ok) {
|
|
971
1451
|
console.warn(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/config",
|
|
3
|
-
"version": "7.7.
|
|
3
|
+
"version": "7.7.2",
|
|
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.
|
|
105
|
+
"@multiplatform.one/utils": "7.7.2"
|
|
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"}
|
package/types/storybook.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/types/vite.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|