diff --git a/.claude/skills/cds-migrator-transform/SKILL.md b/.claude/skills/cds-migrator-transform/SKILL.md new file mode 100644 index 0000000000..210a04c177 --- /dev/null +++ b/.claude/skills/cds-migrator-transform/SKILL.md @@ -0,0 +1,161 @@ +--- +name: cds-migrator-transform +description: | + Use when a CDS change in **cds-web**, **cds-common**, **cds-mobile**, **web-visualization**, or + **mobile-visualization** needs a **jscodeshift** migration in `packages/migrator` to update callers or + mitigate breaking API or import moves (add or change a transform, tests, or preset entry). +allowed-tools: Read, Grep, Glob, StrReplace, Bash(yarn nx run:*), call_mcp_tool +argument-hint: ' — [preset or standalone] — [web|mobile|both] — [optional: Sourcegraph scope / repos / queries the user supplies]' +--- + +# CDS migrator transform (jscodeshift) + +Adds or updates a **jscodeshift** transform under `packages/migrator/src/transforms/`. + +**Where to put files** is **not** always a “version” folder. Choose a subdirectory (or root) that fits the work: + +- **Major / preset migrations** often use a version-style folder (`v9/`, `v10/`, …) aligned with a preset such as `v8-to-v9`. +- **Other codemods** (rename, internal API move, one-off cleanup) can live under any clear grouping the team agrees on (`v9/` still, a feature folder, or directly under `transforms/`). + +Follow the steps in order unless the user already locked scope. + +## Prerequisites + +- **Nx + yarn**: run migrator commands as `yarn nx run migrator:` (see repo `AGENTS.md`). +- **Sourcegraph MCP (strongly recommended)**: Before calling Sourcegraph tools, read the tool schema under `mcps/user-sourcegraph/tools/` (e.g. `sourcegraph_search.json`, `sourcegraph_fetch_file.json`). If Sourcegraph is not configured, tell the user to add the **Sourcegraph** MCP server in Cursor MCP settings and authenticate if required, then continue with workspace `grep` or whatever source the user provides. **Do not invent search queries or repo filters in this skill**—use the symbols, repositories, queries, or links the **user** gives you; if they omitted search context, ask what to search before assuming scope. + +--- + +## 1 — Define the migration + +Capture explicitly: + +1. **Symbol(s)** to migrate (export name, import path, prop name, type name, etc.). +2. **Desired outcome**: rename, change import path/module, replace expression, map enum/string values, add local type alias, etc. +3. **Preset (if any) and on-disk location**: whether this ships in a preset (`packages/migrator/src/presets//manifest.json`) or runs **only via `-t `** without a preset entry. Pick the directory under `transforms/` for the new files (versioned `v9/` / `v10/`, or another name, or `transforms/` root). **Align the manifest `file` field with that path** when you add an entry (see step 7). + +If anything is ambiguous, ask the user before coding. + +--- + +## 2 — Platform scope: one transform or two? + +1. **Web-only** (e.g. CSS, DOM, `@coinbase/cds-web`): single transform, typically under `transforms//-web.ts` or a neutral name if only web is affected. +2. **Mobile-only** (e.g. React Native, `@coinbase/cds-mobile`): single transform for mobile. +3. **Both** with **different** replacement rules (e.g. `DimensionValue` → web local alias vs RN import): **two** transforms (`…-web.ts`, `…-mobile.ts`). +4. **Both** with **identical** AST changes: one transform is enough. + +Document in the transform file header **what** is migrated and **what is not** (re-exports, `require`, dynamic import, etc.). + +--- + +## 3 — Research usage (Sourcegraph + repo) + +1. **Inputs from the user**: They should supply what to look for—symbol names, old/new APIs, repos or orgs to include, example file paths, or concrete Sourcegraph queries. **Follow that source of truth**; do not rely on fixed query templates in this skill. +2. **Sourcegraph MCP**: Run searches and fetches using the user’s queries and scope. Read MCP tool schemas first. Use `sourcegraph_fetch_file` when line previews are not enough. +3. **This monorepo**: Supplement with `grep` / `Glob` under `packages/` when the migration touches CDS itself or when the user asks for in-repo usage. + +Record a short list of **patterns you actually saw** in the results (import style, re-exports, edge cases)—derived from discovery, not from a checklist in this doc. + +**OSS hygiene**: Research may reference internal repos or paths. Anything **committed** to this repo (fixtures, test comments, JSDoc) must stay **generic**: neutral component names, no internal hostnames or file paths, no product codenames. Describe fixtures as “representative pattern” or “composite example” only—see §6. + +--- + +## 4 — Case matrix and user confirmation + +From research, build a table: + +| Case | Example | Automate in codemod? | If not: strategy | +| ---- | ------- | -------------------- | ------------------------------ | +| … | … | Yes / No | TODO comment / skip / doc only | + +**Stop and confirm with the user** which rows to automate vs leave manual/TODO-only before implementing non-trivial logic. Call out gaps that **their** search surfaced but the AST transform will not handle (re-exports, dynamic imports, etc., as applicable). + +--- + +## 5 — Implement transforms + +**Location**: `packages/migrator/src/transforms//.ts`, or `transforms/.ts` at the transforms root. The manifest `file` value (when used) must be the path **relative to `transforms/`** without extension, e.g. `v9/my-transform`, `v10/my-transform`, or `my-oneoff/my-transform`. + +**Patterns**: + +- Default export: `export default function transformer(file, api, options)` (required by jscodeshift; `no-restricted-exports` is off for `packages/migrator/src/transforms/**/*.ts` in root `eslint.config.mjs`). +- Import **`transformLogger`**, **`addTodoComment`**, **`hasMigrationTodo`** from `transform-utils`. Typical depths: **`../../utils/transform-utils`** from `transforms//.ts`; **`../utils/transform-utils`** from `transforms/.ts`. If you nest deeper under `transforms/`, add one `../` per extra level. +- **Package scope from jscodeshift `options`**: When matching or rewriting **`@/cds-…`** import paths, use **`getPackageScopeFromOptions(options)`** from **`../../utils/package-scope`** (same depth pattern as `transform-utils`). The cds-migrator CLI forwards **`--packageScope`** / **`-ps`** into `options.packageScope` (`coinbase` or `@coinbase` both normalize to `@coinbase`). **If set**, only rewrite modules under that scope; **if omitted**, match any scope (e.g. regex like `@…/cds-common/…`). State this in the transform’s file header so consumers know they can narrow runs. Reference: `packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts` and `packages/migrator/src/utils/package-scope.ts`. +- **Import mappings from jscodeshift `options`**: When a transform matches imports by their source path, **apply import mappings before running the regex** so that consumers who proxy CDS through a wrapper package (e.g. `@acme/shared/cds → @coinbase/cds-web`) are also covered. Use **`getImportMappingsFromOptions(options)`** and **`applyImportMappings(src, rewrites)`** from **`../../utils/import-mapping`** (same depth pattern as `transform-utils`). The CLI passes mappings via **`--import-mapping '='`** (repeatable flag) or via a **`cds-migrator.config.json`** file at the repo root / target path (`"importMappings": [{ "from": "...", "to": "..." }]`); CLI values win on conflicts. Apply the rewrite only for **matching**—never mutate the actual `source.value` in the AST unless the transform is explicitly rewriting import paths. Reference: `packages/migrator/src/transforms/v9/button-variant-values.ts` and `packages/migrator/src/utils/import-mapping.ts`. +- Prefer **constants** and small helpers in the transform file. Use **`packages/migrator/src/utils/`** for shared plumbing (logging, package scope, import mappings, import helpers); keep **migration-specific** rules and rewrites in the transform itself rather than in extra modules that only exist to share logic between codemods. +- **Idempotency**: second run should no-op when migration is complete. +- **TODO path**: for dynamic or ambiguous AST, insert a standard CDS migration TODO via `addTodoComment` and log with `transformLogger.warn`. + +Reference examples in-repo: `transforms/v9/` (`migrate-use-merge-refs.ts`, `button-variant-values.ts`, `migrate-layout-types-*.ts`). + +--- + +## 6 — Tests and fixtures + +**Goal**: exhaustive **behavior** coverage with **minimal** on-disk goldens. Follow what `transforms/v9/__tests__/` does today. + +### Inline-first (default) + +1. **Test file**: `packages/migrator/src/transforms//__tests__/.test.ts`. +2. **Most cases** live **inline** as string sources. Prefer **`runInlineTest(transform, options, { path: '…', source: \`…\` }, expectedString, tsxTestOptions)`** from **`jscodeshift/src/testUtils`** for the full transformed output (or **`''`** when the codemod should no-op). Avoid **`applyTransform`** + fragment `expect`s unless there is a rare reason not to pin an exact golden. +3. **Imports**: **`tsxTestOptions`** from **`../../../test-utils/codemodTestUtils`** (path depth matches one `__tests__/` folder under `transforms//`). That module currently exports only **`tsxTestOptions`** (`{ parser: 'tsx' }`). +4. Mock **`console.log` / `console.warn`** when transforms log during tests (see existing v9 tests). + +Cover: patterns from the user’s research, **idempotency**, **no-op** when nothing migrates, **scope-aware** behavior (`packageScope` / `-ps`) via `runInlineTest(transform, { packageScope: '@coinbase' }, …)`. + +### Paired file fixtures (sparingly) + +Use **at most one or two** paired **`foo.input.tsx` / `foo.output.tsx`** per transform suite for **larger composite** examples (multi-import layouts, full component excerpts). Everything else stays inline. + +1. **Directory**: `packages/migrator/src/transforms//__testfixtures__//` (e.g. `` matches the transform id: `button-variant-values`, `migrate-use-merge-refs`). +2. **Run paired tests** with **`runTest(__dirname, '', {}, '/', tsxTestOptions)`** — the prefix is **`suite-folder/base`** without extension. +3. **List E2E cases explicitly** in the test file, e.g. `const E2E_PAIRED_PREFIXES = ['button-variant-values/e2e-survey-confirmation-panel', …] as const` and **`it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { runTest(…); })`**. Do not assume a filesystem discovery helper exists. + +### OSS-safe comments + +- Fixture files and test JSDoc must **not** cite internal hosts, confidential repos, real consumer file paths, or product-specific component names used in research. +- Prefer short comments like **“Representative pattern: …”** or **“Composite … props”** and **fictional** export names (`SurveyConfirmationPanel`, `ChatToolbarActions`, not internal codenames). +- Sourcegraph remains valuable **during development**; keep findings out of committed strings unless they are already public (e.g. this monorepo). + +### Prettier + +**`__testfixtures__` is in `.prettierignore`**—golden outputs must match the codemod’s **`toSource()`** exactly. + +**Run until green**: + +```bash +yarn nx run migrator:test --testPathPattern='' +yarn nx run migrator:typecheck +yarn nx format:write --projects=migrator +# or format only non-fixture sources, e.g.: +# yarn nx format:write --files= +yarn nx run migrator:lint +``` + +Reference tests: `transforms/v9/__tests__/button-variant-values.test.ts`, `migrate-use-merge-refs.test.ts`, `migrate-layout-types-web.test.ts`, `migrate-layout-types-mobile.test.ts`. + +--- + +## 7 — Preset manifest + +**Only if** this codemod should appear in a preset (major upgrade bundle, curated migration set, etc.): add an entry to `packages/migrator/src/presets//manifest.json`: + +- **`name`**: stable CLI identifier. +- **`description`**: short, user-facing. +- **`file`**: path relative to `transforms/` without extension—must match where the file actually lives (e.g. `v9/my-transform`, `v10/my-transform`, or `some-group/my-transform`). + +**Non-version / standalone codemods** may **omit** the preset entirely and still be run with the migrator CLI (e.g. `-t `). See `packages/migrator/docs/PRESETS_AND_TRANSFORMS.md`. If omitted from any preset, say so in the PR/summary. + +--- + +## Checklist (before finishing) + +- [ ] User confirmed automatable vs manual cases. +- [ ] Web/mobile split matches real replacement behavior. +- [ ] Coverage matches **sources, scopes, and cases** the user specified (anything out of scope is documented). +- [ ] Tests: **inline cases** for edges + **0–2** paired E2E fixtures; **OSS-safe** names/comments; `migrator:test`, `migrator:lint`, and `migrator:typecheck` pass; formatting applied (non-fixture sources as needed). +- [ ] If the transform is preset-backed: manifest entry added and `file` matches the real path under `transforms/` (no mismatch between folder name and `file`). If standalone: team knows how to invoke it (CLI / docs). +- [ ] Transform header documents limitations (`export … from`, `require`, dynamic import, etc.). +- [ ] If the transform is **scope-aware**: behavior with and without `options.packageScope` / CLI `-ps` is documented and covered by tests where relevant. +- [ ] If the transform **matches import paths**: `applyImportMappings` is called before the regex so wrapper/re-exporting packages are handled; behavior with and without `--import-mapping` / `cds-migrator.config.json` is documented and covered by tests where relevant. diff --git a/.claude/skills/components.styles/SKILL.md b/.claude/skills/components.styles/SKILL.md index 0ddedc2a42..4a19f4498e 100644 --- a/.claude/skills/components.styles/SKILL.md +++ b/.claude/skills/components.styles/SKILL.md @@ -15,8 +15,6 @@ Find the component source file: ```bash packages/web/src/[source-category]/[ComponentName].tsx # for web packages/mobile/src/[source-category]/[ComponentName].tsx # for mobile -packages/web-visualization/src/[source-category]/[ComponentName].tsx # for web visualization -packages/mobile-visualization/src/[source-category]/[ComponentName].tsx # for mobile visualization ``` ## Step 2: Evaluate Component Structure diff --git a/.claude/skills/components.write-docs/SKILL.md b/.claude/skills/components.write-docs/SKILL.md index 60dcc343a0..4024e32b77 100644 --- a/.claude/skills/components.write-docs/SKILL.md +++ b/.claude/skills/components.write-docs/SKILL.md @@ -69,11 +69,6 @@ packages/web/src/[source-category]/[ComponentName].tsx # for web packages/mobile/src/[source-category]/[ComponentName].tsx # for mobile ``` -Also check visualization packages if applicable: - -- `packages/web-visualization/src/...` -- `packages/mobile-visualization/src/...` - Also check for Storybook stories (`packages/*/src/**/__stories__/[ComponentName].stories.tsx`). If one exists, add the `storybook` field to webMetadata.json. ### Check for Styles @@ -684,4 +679,3 @@ Before completing, verify: 3. Ensure all examples work and have proper code snippets 4. Include accessibility section with specific examples 5. Test all examples and props tables render correctly -6. For visualization components, use paths like `web-visualization` or `mobile-visualization` instead of `web` or `mobile` diff --git a/.claude/skills/deprecate-cds-api/SKILL.md b/.claude/skills/deprecate-cds-api/SKILL.md index 8dbc95f9f6..790e017302 100644 --- a/.claude/skills/deprecate-cds-api/SKILL.md +++ b/.claude/skills/deprecate-cds-api/SKILL.md @@ -14,7 +14,7 @@ argument-hint: ' — replacement — [@deprecationExpectedRe # Deprecate CDS public API -Automate the standard CDS deprecation workflow for symbols exported from `packages/web`, `packages/mobile`, `packages/common`, `packages/web-visualization`, or `packages/mobile-visualization`. +Automate the standard CDS deprecation workflow for symbols exported from `packages/web`, `packages/mobile`, or `packages/common`. ## Inputs to confirm first @@ -28,7 +28,7 @@ Automate the standard CDS deprecation workflow for symbols exported from `packag **Deprecate the symbol everywhere it is publicly reachable**, not only where it is first implemented. -1. For each CDS package (`web`, `mobile`, `common`, `web-visualization`, `mobile-visualization`), trace the symbol from that package’s `package.json` **`exports`** map → barrel / `index` files → the module that declares or re-exports the symbol. +1. For each CDS package (`web`, `mobile`, `common`), trace the symbol from that package’s `package.json` **`exports`** map → barrel / `index` files → the module that declares or re-exports the symbol. 2. **`Grep`** for the symbol name under `packages//src` (e.g. `export { Foo`, `export * from`, `Foo as`) to catch re-exports and alternate entry paths. 3. **Every** package that publicly exports the symbol must end up with deprecation coverage: primary implementation **and** any re-export site where your tooling or consumers would not see JSDoc from the source file (add JSDoc on the re-export line or duplicate the tags as needed so imports from `@coinbase/cds-web`, `@coinbase/cds-mobile`, `@coinbase/cds-common`, etc. all surface the deprecation). @@ -69,7 +69,6 @@ The tag must satisfy `@deprecationExpectedRemoval v…` as enforced by ESLint (e 1. **Confirm with the user** which major **`N`** to use, unless they already specified it in **Inputs** (e.g. “remove in v10” → use `v10`). 2. **Default suggestion** when the user wants a recommendation: read the **`version`** field from the relevant `package.json` and set **`N = current major + 1`**. - **`packages/web`**, **`packages/mobile`**, and **`packages/common`** always share the same semver — read **`version`** from any one of them (e.g. `8.60.0` → suggest **`v9`**). - - Symbols owned only by **`packages/web-visualization`** or **`packages/mobile-visualization`**: read **that** package’s `package.json` (those versions are independent from web/mobile/common). 3. After agreeing on **`N`**, use **`@deprecationExpectedRemoval v`** everywhere for this deprecation (same **Step 3**). Do **not** assume the default without checking—either the user names **`N`**, or they accept the suggested next-major after you show the current **`version`**. @@ -141,7 +140,7 @@ Use the workspace convention: yarn nx run :lint ``` -Examples: `web`, `mobile`, `common`, `web-visualization`, `mobile-visualization` — run **each** project you touched. Fix any reported issues before finishing (most often: missing `@deprecationExpectedRemoval`, or `@deprecated` text not ending with the standard sentence). +Examples: `web`, `mobile`, `common` — run **each** project you touched. Fix any reported issues before finishing (most often: missing `@deprecationExpectedRemoval`, or `@deprecated` text not ending with the standard sentence). --- diff --git a/.claude/skills/git.detect-breaking-changes/SKILL.md b/.claude/skills/git.detect-breaking-changes/SKILL.md index 2691175095..d4b57b159e 100644 --- a/.claude/skills/git.detect-breaking-changes/SKILL.md +++ b/.claude/skills/git.detect-breaking-changes/SKILL.md @@ -17,8 +17,6 @@ Only analyze changes within these packages: - `packages/web/` - `packages/mobile/` - `packages/common/` -- `packages/web-visualization/` -- `packages/mobile-visualization/` ## Determining the public API surface @@ -85,7 +83,7 @@ Examples: ### 5. DOM / element structure change (web packages only) -Applies to `packages/web/` and `packages/web-visualization/` only. Changes to the rendered HTML element tree that could break consumer CSS selectors or DOM queries targeting internal component structure. +Applies to `packages/web/` only. Changes to the rendered HTML element tree that could break consumer CSS selectors or DOM queries targeting internal component structure. Examples: diff --git a/.codeflow.yml b/.codeflow.yml index 8a3b60d4d0..37e657b890 100644 --- a/.codeflow.yml +++ b/.codeflow.yml @@ -6,7 +6,6 @@ secure: branches: - master - release-8.x - - cds-v9-master auto_assign_reviewers: true # Marks upstream_repository: coinbase/cds @@ -124,11 +123,11 @@ build: expire_tmp_tags_after_days: 1 expire_all_after_days: 2 - BaldurNode: - name: package-ui-mobile-playground - path: ./packages/ui-mobile-playground/publish.Dockerfile + name: package-ui-scorecard + path: ./packages/ui-scorecard/publish.Dockerfile architecture: amd64 autobuild_files: - - packages/ui-mobile-playground/package.json + - packages/ui-scorecard/package.json expire_keep_tags_after_days: 1 expire_tmp_tags_after_days: 1 expire_all_after_days: 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66fc1f0e75..8311d6d0f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: fetch-depth: 100 # TODO: This needs to include the merge-base - uses: ./.github/actions/setup - name: Build - run: yarn nx affected --exclude=mobile-app --target=build --base=$NX_BASE --head=$NX_HEAD + run: yarn nx affected --exclude=expo-app --target=build --base=$NX_BASE --head=$NX_HEAD depcheck: name: Depcheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f0fd51af5..59f0606020 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,6 @@ on: branches: - master - 'release-[0-9]+.x' - - 'cds-v9-master' paths: - 'packages/*/package.json' pull_request: @@ -60,7 +59,7 @@ jobs: run: | # Define all publishable packages (those with publish.Dockerfile # and not private: true) - ALL_PACKAGES="common,eslint-plugin-cds,icons,illustrations,lottie-files,mcp-server,mobile,mobile-visualization,ui-mobile-playground,utils,web,web-visualization" + ALL_PACKAGES="common,eslint-plugin-cds,icons,illustrations,lottie-files,mcp-server,migrator,mobile,mobile-visualization,utils,web,web-visualization" # Function to convert comma-separated list to JSON array csv_to_json() { @@ -243,7 +242,25 @@ jobs: if: steps.version-check.outputs.should-publish == 'true' run: | cd packages/${{ matrix.package }} + PACKAGE_VERSION=$(node -p "require('./package.json').version") + CURRENT_BRANCH="${{ github.base_ref || github.ref_name }}" + NPM_TAG_ARGS=() + + if echo "$CURRENT_BRANCH" | grep -qE '^release-[0-9]+\.x$'; then + MAJOR=$(echo "$CURRENT_BRANCH" | sed -E 's/release-([0-9]+)\.x/\1/') + NPM_TAG="v${MAJOR}" + echo "🏷️ Dry run publishing from ${CURRENT_BRANCH}, using dist-tag: ${NPM_TAG}" + NPM_TAG_ARGS=(--tag "$NPM_TAG") + elif echo "$PACKAGE_VERSION" | grep -qE '\-'; then + PRERELEASE_TAG=$(echo "$PACKAGE_VERSION" | sed -E 's/.*-([^.]+).*/\1/') + echo "🏷️ Dry run detected prerelease version, using dist-tag: $PRERELEASE_TAG" + NPM_TAG_ARGS=(--tag "$PRERELEASE_TAG") + else + echo "🏷️ Dry run publishing as latest" + fi + npm publish --dry-run --access public \ + "${NPM_TAG_ARGS[@]}" \ --registry https://registry.npmjs.org echo "✅ Dry run successful for ${{ matrix.package }}" diff --git a/.github/workflows/visreg-mobile.yml b/.github/workflows/visreg-mobile.yml index 0de82b16d5..5c149e1b04 100644 --- a/.github/workflows/visreg-mobile.yml +++ b/.github/workflows/visreg-mobile.yml @@ -124,7 +124,7 @@ jobs: run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Prepare iOS app (extract prebuild + patch JS bundle) - run: yarn nx run mobile-app:patch-bundle-ios + run: yarn nx run expo-app:patch-bundle-ios - name: Boot iOS simulator run: | @@ -136,7 +136,7 @@ jobs: sleep 30 - name: Install app on simulator - run: xcrun simctl install booted apps/mobile-app/prebuilds/ios-release-hermes.app + run: xcrun simctl install booted apps/expo-app/prebuilds/ios-release/expoapp.app - name: Capture screenshots run: yarn nx run mobile-visreg:ios @@ -218,7 +218,7 @@ jobs: # run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH # - name: Prepare Android app (extract prebuild + patch JS bundle) - # run: yarn nx run mobile-app:patch-bundle-android + # run: yarn nx run expo-app:patch-bundle-android # # Enable KVM hardware acceleration for the Android emulator. # # Without this, the emulator runs in software emulation mode, which takes 6+ minutes to boot @@ -255,7 +255,7 @@ jobs: # # the package manager specifically before attempting install. # while ! adb shell pm list packages > /dev/null 2>&1; do echo "Waiting for package manager..."; sleep 1; done - # adb install -r apps/mobile-app/prebuilds/android-release-hermes/binary.apk + # adb install -r apps/expo-app/prebuilds/android-release-hermes/binary.apk # # Copy Maestro debug artifacts after the run so they can be uploaded after the emulator shuts down # yarn nx run mobile-visreg:android; cp -r ~/.maestro/tests /tmp/maestro-debug || true diff --git a/.gitignore b/.gitignore index d3d2e3ec7f..4895c3a49a 100644 --- a/.gitignore +++ b/.gitignore @@ -152,13 +152,15 @@ web-build/ ios android -# failed detox test screenshots -apps/mobile-app/artifacts -apps/mobile-app/prebuilds/android-* -!apps/mobile-app/prebuilds/android-release-*.zip -!apps/mobile-app/credentials/android-release-*.keystore -# temporary build directory for native compilation -apps/mobile-app/build/ +# expo-app generated artifacts +apps/expo-app/artifacts +apps/expo-app/build/ +# we expect developers to generate their own debug builds for local development +apps/expo-app/prebuilds/ios-debug/ +apps/expo-app/prebuilds/android-debug/ +# android release builds are too large to be committed at this time +# committed prebuilds are especially important for running visreg in CI so we will need to figure out a solution when we turn android tests back on +apps/expo-app/prebuilds/android-release/ #reassure **/.reassure/* diff --git a/.prettierignore b/.prettierignore index 2500464492..4a3f850f68 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,9 @@ **/persisted_queries.json **/__generated__ +# Test fixtures (must match exact transform output) +**/__testfixtures__/ + # Builds dist/ lib/ diff --git a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch b/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch deleted file mode 100644 index 25dd517fa3..0000000000 --- a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch +++ /dev/null @@ -1,52 +0,0 @@ -diff --git a/build/src/start/platforms/android/AndroidAppIdResolver.js b/build/src/start/platforms/android/AndroidAppIdResolver.js -index f4b217c5d71fb62179160cdbf8e02276abd06a6d..74d58fee13c7dbb5144b6c77d6e12917dc62958d 100644 ---- a/build/src/start/platforms/android/AndroidAppIdResolver.js -+++ b/build/src/start/platforms/android/AndroidAppIdResolver.js -@@ -31,7 +31,7 @@ class AndroidAppIdResolver extends _appIdResolver.AppIdResolver { - async resolveAppIdFromNativeAsync() { - const applicationIdFromGradle = await _configPlugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); - if (applicationIdFromGradle) { -- return applicationIdFromGradle; -+ return `${applicationIdFromGradle}.development`; - } - try { - var ref, ref1; -diff --git a/build/src/start/platforms/ios/AppleAppIdResolver.js b/build/src/start/platforms/ios/AppleAppIdResolver.js -index 06d6d1e11802ed88388444b10acd83834e079f50..c4409c566377897eacdb78aea4a8fd78d5aeca03 100644 ---- a/build/src/start/platforms/ios/AppleAppIdResolver.js -+++ b/build/src/start/platforms/ios/AppleAppIdResolver.js -@@ -50,7 +50,7 @@ class AppleAppIdResolver extends _appIdResolver.AppIdResolver { - async resolveAppIdFromNativeAsync() { - // Check xcode project - try { -- const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); -+ const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); - if (bundleId) { - return bundleId; - } -diff --git a/build/src/utils/npm.js b/build/src/utils/npm.js -index a49faac0ba81f0a00d19e8427434cf013c9b4769..cb9c81ae46f1e2637bbb5410412b578225064818 100644 ---- a/build/src/utils/npm.js -+++ b/build/src/utils/npm.js -@@ -171,7 +171,7 @@ async function extractNpmTarballAsync(stream, props) { - transformStream.on("data", (chunk)=>{ - hash.update(chunk); - }); -- await pipeline(stream, transformStream, _tar().default.extract({ -+ await pipeline(stream, transformStream, (_tar().default ?? _tar()).extract({ - cwd, - filter, - onentry: (0, _createFileTransform.createEntryResolver)(name), -diff --git a/build/src/utils/tar.js b/build/src/utils/tar.js -index 8bf12d812646724089f6cd265b16093e8f518570..9575eaeae41fe05105ab0c2af2ee3d11dce659bf 100644 ---- a/build/src/utils/tar.js -+++ b/build/src/utils/tar.js -@@ -86,7 +86,7 @@ async function extractAsync(input, output) { - debug(`Extracting ${input} to ${output} using JS tar module`); - // tar node module has previously had problems with big files, and seems to - // be slower, so only use it as a backup. -- await _tar().default.extract({ -+ await (_tar().default ?? _tar()).extract({ - file: input, - cwd: output - }); diff --git a/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch new file mode 100644 index 0000000000..0a4b594dd4 --- /dev/null +++ b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch @@ -0,0 +1,26 @@ +diff --git a/build/src/start/platforms/android/AndroidAppIdResolver.js b/build/src/start/platforms/android/AndroidAppIdResolver.js +index eedb068830f3d5869bfc594671c254f48cd9ab8a..38b728b747bc8974ad8ab0fd38f271c771b0519a 100644 +--- a/build/src/start/platforms/android/AndroidAppIdResolver.js ++++ b/build/src/start/platforms/android/AndroidAppIdResolver.js +@@ -33,7 +33,7 @@ class AndroidAppIdResolver extends _AppIdResolver.AppIdResolver { + async resolveAppIdFromNativeAsync() { + const applicationIdFromGradle = await _configplugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); + if (applicationIdFromGradle) { +- return applicationIdFromGradle; ++ return `${applicationIdFromGradle}.development`; + } + try { + var _androidManifest_manifest_$, _androidManifest_manifest; +diff --git a/build/src/start/platforms/ios/AppleAppIdResolver.js b/build/src/start/platforms/ios/AppleAppIdResolver.js +index 96cc53df6109e3b62ede2e79bf4093598a24fa5b..9ec60b4477480128f28c5eb1394caf60e65d8072 100644 +--- a/build/src/start/platforms/ios/AppleAppIdResolver.js ++++ b/build/src/start/platforms/ios/AppleAppIdResolver.js +@@ -52,7 +52,7 @@ class AppleAppIdResolver extends _AppIdResolver.AppIdResolver { + async resolveAppIdFromNativeAsync() { + // Check xcode project + try { +- const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); ++ const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); + if (bundleId) { + return bundleId; + } diff --git a/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch new file mode 100644 index 0000000000..3fca734af2 --- /dev/null +++ b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch @@ -0,0 +1,19 @@ +diff --git a/build/serializer/environmentVariableSerializerPlugin.js b/build/serializer/environmentVariableSerializerPlugin.js +index 3b13e076369a5a94ec3dc7fddb905b01e52d91e8..c42cdebf000b8bd53c5eb45bfbbb2f6492e20b1c 100644 +--- a/build/serializer/environmentVariableSerializerPlugin.js ++++ b/build/serializer/environmentVariableSerializerPlugin.js +@@ -17,6 +17,14 @@ function getTransformEnvironment(url) { + function getAllExpoPublicEnvVars(inputEnv = process.env) { + // Create an object containing all environment variables that start with EXPO_PUBLIC_ + const env = {}; ++ ++ if (inputEnv._ENV_VARS_FOR_APP) { ++ const keys = JSON.parse(inputEnv._ENV_VARS_FOR_APP); ++ for (const key of keys) { ++ env[key] = inputEnv[key]; ++ } ++ } ++ + for (const key in inputEnv) { + if (key.startsWith('EXPO_PUBLIC_')) { + // @ts-expect-error: TS doesn't know that the key starts with EXPO_PUBLIC_ diff --git a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch b/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch deleted file mode 100644 index d318a3e1b0..0000000000 --- a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -index b7a856d72f271e5d655d256a2ea2774c6d4356bd..49d90a461f0c7a26c72a71b77009ec92c0e94105 100644 ---- a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -+++ b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -@@ -32,7 +32,7 @@ abstract class DevLauncherPlugin : Plugin { - } - - val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) -- androidComponents.onVariants(androidComponents.selector().withBuildType("debug")) { variant -> -+ androidComponents.onVariants(androidComponents.selector().withBuildType("development")) { variant -> - variant.instrumentation.transformClassesWith(DevLauncherClassVisitorFactory::class.java, InstrumentationScope.ALL) { - it.enabled.set(true) - } diff --git a/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch new file mode 100644 index 0000000000..0e1951e2f9 --- /dev/null +++ b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch @@ -0,0 +1,17 @@ +diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm +index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..d14269aae847143318888ad3c848d707de95e691 100644 +--- a/ios/Core/ExpoBridgeModule.mm ++++ b/ios/Core/ExpoBridgeModule.mm +@@ -45,9 +45,9 @@ - (void)setBridge:(RCTBridge *)bridge + _bridge = bridge; + _appContext.reactBridge = bridge; + +-#if !__has_include() +- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; +-#endif // React Native <0.74 ++// #if !__has_include() ++// _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; ++// #endif // React Native <0.74 + } + + #if __has_include() diff --git a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch b/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch deleted file mode 100644 index 22a0caa149..0000000000 --- a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch +++ /dev/null @@ -1,126 +0,0 @@ -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -index f5ac5483aa3f34ae59830a9da16afe52ccc8ba0e..e2bdef4296b1aeecae41681adf43fe6e7fcc3b89 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -@@ -8,6 +8,11 @@ import android.view.ViewGroup - import android.widget.ImageView - import android.widget.RelativeLayout - -+import androidx.core.content.ContextCompat -+import android.view.Gravity -+import android.widget.TextView -+import android.graphics.Color -+ - // this needs to stay for versioning to work - - @SuppressLint("ViewConstructor") -@@ -15,16 +20,44 @@ class SplashScreenView( - context: Context - ) : RelativeLayout(context) { - val imageView: ImageView = ImageView(context).also { view -> -- view.layoutParams = LayoutParams( -+ val params = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT - ) -+ params.addRule(CENTER_IN_PARENT) // Center align -+ view.layoutParams = params - } - - init { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - addView(imageView) -+ -+ // context comes from the application level. -+ val packageName = context.packageName -+ -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ -+ // If bottom image is provided, add it to the view -+ // Otherwise we keep only the main, centered, image -+ if (resId != 0) { -+ val bottomImageView = ImageView(context).apply { -+ val params = LayoutParams( -+ LayoutParams.WRAP_CONTENT, -+ LayoutParams.WRAP_CONTENT -+ ) -+ params.addRule(ALIGN_PARENT_BOTTOM) -+ params.addRule(CENTER_HORIZONTAL) -+ layoutParams = params -+ setPadding(0, 0, 0, 40) -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ if (resId != 0) { -+ setImageResource(resId) -+ } -+ scaleType = ImageView.ScaleType.CENTER -+ } -+ addView(bottomImageView) -+ } - } - - fun configureImageViewResizeMode(resizeMode: SplashScreenImageResizeMode) { -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -index 23e8d4b416bb12192a3fe517f02e0945ccd8c347..16fd58a80216f49d8b9eeaa3a7a27ba8567760b3 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -@@ -7,6 +7,7 @@ import android.view.View - import android.view.ViewGroup - import expo.modules.splashscreen.exceptions.NoContentViewException - import java.lang.ref.WeakReference -+import android.view.animation.AlphaAnimation - - const val SEARCH_FOR_ROOT_VIEW_INTERVAL = 20L - -@@ -63,12 +64,19 @@ open class SplashScreenViewController( - return failureCallback("Cannot hide native splash screen on activity that is already destroyed (application is already closed).") - } - -- Handler(activity.mainLooper).post { -- contentView.removeView(splashScreenView) -- autoHideEnabled = true -- splashScreenShown = false -- successCallback(true) -+ val fadeOutDuration = 300L -+ val fadeOutAnimation = AlphaAnimation(1f, 0f).apply { -+ duration = fadeOutDuration -+ fillAfter = true - } -+ -+ Handler(activity.mainLooper).postDelayed({ -+ contentView.removeView(splashScreenView) -+ }, fadeOutDuration) -+ splashScreenView.startAnimation(fadeOutAnimation) -+ autoHideEnabled = true -+ splashScreenShown = false -+ successCallback(true) - } - - // endregion -diff --git a/ios/EXSplashScreen/EXSplashScreenViewController.m b/ios/EXSplashScreen/EXSplashScreenViewController.m -index 3f1226e3867c7b3ef663a3b56787975006d60ddf..3361283632abc49143e59f93c8e57b57324f1708 100644 ---- a/ios/EXSplashScreen/EXSplashScreenViewController.m -+++ b/ios/EXSplashScreen/EXSplashScreenViewController.m -@@ -72,12 +72,16 @@ - (void)hideWithCallback:(nullable void(^)(BOOL))successCallback - EX_WEAKIFY(self); - dispatch_async(dispatch_get_main_queue(), ^{ - EX_ENSURE_STRONGIFY(self); -- [self.splashScreenView removeFromSuperview]; -- self.splashScreenShown = NO; -- self.autoHideEnabled = YES; -- if (successCallback) { -- successCallback(YES); -- } -+ [UIView animateWithDuration:0.2 // 200ms fade-out animation -+ animations:^{self.splashScreenView.alpha = 0.0;} -+ completion:^(BOOL finished){ -+ [self.splashScreenView removeFromSuperview]; -+ self.splashScreenShown = NO; -+ self.autoHideEnabled = YES; -+ if (successCallback) { -+ successCallback(YES); -+ } -+ }]; - }); - } - diff --git a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch b/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch deleted file mode 100644 index d45460a62a..0000000000 --- a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch +++ /dev/null @@ -1,41 +0,0 @@ -diff --git a/sync.js b/sync.js ---- a/sync.js -+++ b/sync.js -@@ -18,6 +18,10 @@ var ownProp = common.ownProp - var childrenIgnored = common.childrenIgnored - var isIgnored = common.isIgnored - -+function safeJoin (arr) { -+ return arr.map(function (p) { return typeof p === 'symbol' ? '**' : p }).join('/') -+} -+ - function globSync (pattern, options) { - if (typeof options === 'function' || arguments.length === 3) - throw new TypeError('callback provided to sync glob\n'+ -@@ -89,7 +93,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - switch (n) { - // if not, then this is rather simple - case pattern.length: -- this._processSimple(pattern.join('/'), index) -+ this._processSimple(safeJoin(pattern), index) - return - - case 0: -@@ -102,7 +106,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - // pattern has some string bits in the front. - // whatever it starts with, whether that's 'absolute' like /foo/bar, - // or 'relative' like '../baz' -- prefix = pattern.slice(0, n).join('/') -+ prefix = safeJoin(pattern.slice(0, n)) - break - } - -@@ -112,7 +116,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - var read - if (prefix === null) - read = '.' -- else if (isAbsolute(prefix) || isAbsolute(pattern.join('/'))) { -+ else if (isAbsolute(prefix) || isAbsolute(safeJoin(pattern))) { - if (!prefix || !isAbsolute(prefix)) - prefix = '/' + prefix - read = prefix diff --git a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch b/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch deleted file mode 100644 index 4b7ce66e94..0000000000 --- a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/src/handlers/gestures/GestureDetector.tsx b/src/handlers/gestures/GestureDetector.tsx -index 45d927c230a86a7713d097f19e97da9c32563e2d..06c8d1d441957f29f899af5efd533cab25dd690d 100644 ---- a/src/handlers/gestures/GestureDetector.tsx -+++ b/src/handlers/gestures/GestureDetector.tsx -@@ -256,8 +256,29 @@ function updateHandlers( - ) { - gestureConfig.prepare(); - -+ /* Patch added to fix performance regression due to SharedValue reads. As -+ * per this discussion https://github.com/software-mansion/react-native-gesture-handler/commit/1217039146ddcae6796820b5ecf19d1ff51af837#r143406410 -+ * -+ * Remove patch if this change -+ * https://github.com/software-mansion/react-native-gesture-handler/pull/2957 -+ * has landed on the version you upgrade to. -+ */ -+ // if amount of gesture configs changes, we need to update the callbacks in shared value -+ let shouldUpdateSharedValueIfUsed = -+ preparedGesture.config.length !== gesture.length; -+ - for (let i = 0; i < gesture.length; i++) { - const handler = preparedGesture.config[i]; -+ -+ // if the gestureId is different (gesture isn't wrapped with useMemo or its dependencies changed), -+ // we need to update the shared value, assuming the gesture runs on UI thread or the thread changed -+ if ( -+ handler.handlers.gestureId !== gesture[i].handlers.gestureId && -+ (gesture[i].shouldUseReanimated || handler.shouldUseReanimated) -+ ) { -+ shouldUpdateSharedValueIfUsed = true; -+ } -+ - checkGestureCallbacksForWorklets(handler); - - // only update handlerTag when it's actually different, it may be the same -@@ -301,34 +322,13 @@ function updateHandlers( - } - - if (preparedGesture.animatedHandlers) { -- const previousHandlersValue = -- preparedGesture.animatedHandlers.value ?? []; -- const newHandlersValue = preparedGesture.config -- .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -- .map((g) => g.handlers) as unknown as HandlerCallbacks< -- Record -- >[]; -- -- // if amount of gesture configs changes, we need to update the callbacks in shared value -- let shouldUpdateSharedValue = -- previousHandlersValue.length !== newHandlersValue.length; -- -- if (!shouldUpdateSharedValue) { -- // if the amount is the same, we need to check if any of the configs inside has changed -- for (let i = 0; i < newHandlersValue.length; i++) { -- if ( -- // we can use the `gestureId` prop as it's unique for every config instance -- newHandlersValue[i].gestureId !== previousHandlersValue[i].gestureId -- ) { -- shouldUpdateSharedValue = true; -- break; -- } -- } -- } -- -- if (shouldUpdateSharedValue) { -- preparedGesture.animatedHandlers.value = newHandlersValue; -- } -+ if (shouldUpdateSharedValueIfUsed) { -+ preparedGesture.animatedHandlers.value = preparedGesture.config -+ .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -+ .map((g) => g.handlers) as unknown as HandlerCallbacks< -+ Record -+ >[]; -+ } - } - - scheduleFlushOperations(); diff --git a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch b/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch deleted file mode 100644 index a09c674667..0000000000 --- a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch +++ /dev/null @@ -1,36 +0,0 @@ -diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m -index b0295e05ae4d54091bd80f77809ca2aeaaa8562b..81f8f4fa738cfe80ec89f32ebe5bab7ed21f5958 100644 ---- a/React/Views/RCTModalHostViewManager.m -+++ b/React/Views/RCTModalHostViewManager.m -@@ -75,7 +75,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - modalHostView.onShow(nil); - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_presentationBlock) { - self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else { -@@ -83,7 +82,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - animated:animated - completion:completionBlock]; - } -- }); - } - - - (void)dismissModalHostView:(RCTModalHostView *)modalHostView -@@ -95,7 +93,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_dismissalBlock) { - self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else if (viewController.presentingViewController) { -@@ -106,7 +103,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - // This, somehow, invalidate the presenting view controller and the modal remains always visible. - completionBlock(); - } -- }); - } - - - (RCTShadowView *)shadowView diff --git a/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch new file mode 100644 index 0000000000..c5e401ba99 --- /dev/null +++ b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch @@ -0,0 +1,92 @@ +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +index 9d663610a0546d4f801196217966ad9d184818af..1586d116b9fc4e86a39976de543489c6a23a1154 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +@@ -16868,7 +16868,7 @@ __DEV__ && + shouldSuspendImpl = newShouldSuspendImpl; + }; + var isomorphicReactPackageVersion = React.version; +- if ("19.1.0" !== isomorphicReactPackageVersion) ++ if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +index b3d1cfa09d76b50617b9032b15c82351a699638e..c17f99912028e52b9acfb01b9b9560bba8c03c16 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +@@ -10603,7 +10603,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +index b317ca102b0b7d25c15819c61352019fef05561b..e5c3854d0d6de8c9181d6a50124fdc7e8ddc72ab 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +@@ -11245,7 +11245,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m +index 203d0b441342487bfd8765b93044b291029614b2..1f2abc9651d3a4c809be6a03e8d9f7d6f7bd12bc 100644 +--- a/React/Views/RCTModalHostViewManager.m ++++ b/React/Views/RCTModalHostViewManager.m +@@ -60,7 +60,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + modalHostView.onShow(nil); + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_presentationBlock) { + self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else { +@@ -68,7 +68,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + animated:animated + completion:completionBlock]; + } +- }); ++ + } + + - (void)dismissModalHostView:(RCTModalHostView *)modalHostView +@@ -80,7 +80,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_dismissalBlock) { + self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else if (viewController.presentingViewController) { +@@ -91,7 +91,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + // This, somehow, invalidate the presenting view controller and the modal remains always visible. + completionBlock(); + } +- }); ++ + } + + - (RCTShadowView *)shadowView +diff --git a/sdks/hermes-engine/hermes-engine.podspec b/sdks/hermes-engine/hermes-engine.podspec +index 326c6fa9089cf794c2dcf37084085bf3bef3f6a5..4aa7b70780af967ff607aada3419959a8be49670 100644 +--- a/sdks/hermes-engine/hermes-engine.podspec ++++ b/sdks/hermes-engine/hermes-engine.podspec +@@ -77,7 +77,7 @@ Pod::Spec.new do |spec| + . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" + + CONFIG="Release" +- if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "DEBUG=1"; then ++ if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "HERMES_ENABLE_DEBUGGER=1"; then + CONFIG="Debug" + fi + diff --git a/AGENTS.md b/AGENTS.md index 8e6e41a6bb..15a16afdf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,9 @@ Runtime: NodeJS (see .nvmrc for version) - **`packages/common/`** - Shared functionality and types (`@coinbase/cds-common`) - **`packages/icons/`** - Icon definitions and data (`@coinbase/cds-icons`) - **`packages/illustrations/`** - Illustration assets (`@coinbase/illustrations`) -- **`packages/web-visualization/`** - Web visualization components built with D3 (`@coinbase/cds-web-visualization`) -- **`packages/mobile-visualization/`** - Mobile visualization components built with D3 and react-native-skia (`@coinbase/cds-mobile-visualization`) - **`apps/docs/`** - Public documentation website (Docusaurus) - **`apps/storybook/`** - Component development and testing environment for cds-web -- **`apps/mobile-app/`** - Sample React Native app for testing components from cds-mobile +- **`apps/expo-app/`** - Expo app for testing and visual regression of CDS mobile components ## Standards & Best Practices diff --git a/README.md b/README.md index 802c816002..c4b5f5bdcc 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ yarn nx run docs:dev ```sh # Launch local debug builds -yarn nx run mobile-app:launch:ios-debug -yarn nx run mobile-app:launch:android-debug +yarn nx run expo-app:launch:ios-debug +yarn nx run expo-app:launch:android-debug ``` ## Contributing diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index 41202d5910..f514aa3175 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -21,9 +21,7 @@ module.exports = { */ entryPoints: [ path.join(__dirname, '../../packages/web/tsconfig.json'), - path.join(__dirname, '../../packages/web-visualization/tsconfig.json'), path.join(__dirname, '../../packages/mobile/tsconfig.json'), - path.join(__dirname, '../../packages/mobile-visualization/tsconfig.json'), path.join(__dirname, '../../packages/common/tsconfig.json'), path.join(__dirname, '../../packages/icons/tsconfig.json'), path.join(__dirname, '../../packages/illustrations/tsconfig.json'), @@ -68,18 +66,18 @@ module.exports = { 'cells/CellMedia', 'cells/ContentCell', 'cells/ListCell', - 'chart/area/AreaChart', - 'chart/bar/BarChart', - 'chart/bar/PercentageBarChart', - 'chart/CartesianChart', - 'chart/legend/Legend', - 'chart/line/LineChart', - 'chart/line/ReferenceLine', - 'chart/axis/XAxis', - 'chart/axis/YAxis', - 'chart/PeriodSelector', - 'chart/point/Point', - 'chart/scrubber/Scrubber', + 'visualizations/chart/area/AreaChart', + 'visualizations/chart/bar/BarChart', + 'visualizations/chart/bar/PercentageBarChart', + 'visualizations/chart/CartesianChart', + 'visualizations/chart/legend/Legend', + 'visualizations/chart/line/LineChart', + 'visualizations/chart/line/ReferenceLine', + 'visualizations/chart/axis/XAxis', + 'visualizations/chart/axis/YAxis', + 'visualizations/chart/PeriodSelector', + 'visualizations/chart/point/Point', + 'visualizations/chart/scrubber/Scrubber', 'chips/Chip', 'chips/InputChip', 'chips/MediaChip', @@ -164,11 +162,11 @@ module.exports = { 'visualizations/ProgressBarWithFloatLabel', 'visualizations/ProgressCircle', 'section-header/SectionHeader', - 'sparkline/Sparkline', + 'visualizations/sparkline/Sparkline', 'stepper/Stepper', - 'sparkline/SparklineGradient', - 'sparkline/sparkline-interactive/SparklineInteractive', - 'sparkline/sparkline-interactive-header/SparklineInteractiveHeader', + 'visualizations/sparkline/SparklineGradient', + 'visualizations/sparkline/sparkline-interactive/SparklineInteractive', + 'visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader', 'system/Interactable', 'system/MediaQueryProvider', 'system/Pressable', diff --git a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json index c172e168a0..3abaf8ff25 100644 --- a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json +++ b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json index 1ba88778e5..ae92a6b54d 100644 --- a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json +++ b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx index 2a1a0da348..90330f6863 100644 --- a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx @@ -81,7 +81,7 @@ function Example() { @@ -94,7 +94,12 @@ function Example() { subtitle: 'Sept earnings', onPress: NoopFn, header: ( - + ), diff --git a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx index 7d5d1c194d..62bcdceea5 100644 --- a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx @@ -126,7 +126,7 @@ function Example() { ## With Background -Apply a background color to the card using the `background` prop. When using a background, consider using `variant="tertiary"` on buttons. +Apply a background color to the card using the `background` prop. When using a background, consider using `variant="inverse"` on buttons. ```jsx function Example() { @@ -153,7 +153,7 @@ function Example() { - @@ -275,7 +275,7 @@ function AccessibleCard() { @@ -259,9 +259,9 @@ function Example() { - + Reward - + +$15 ACS @@ -317,7 +317,7 @@ function AccessibleCard() { + @@ -46,6 +50,12 @@ Use transparent buttons for supplementary actions with lower prominence. The con + + diff --git a/apps/docs/docs/components/inputs/Button/_webExamples.mdx b/apps/docs/docs/components/inputs/Button/_webExamples.mdx index 4100dc2206..c4e4ad15c8 100644 --- a/apps/docs/docs/components/inputs/Button/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Button/_webExamples.mdx @@ -14,7 +14,8 @@ Use variants to communicate the importance and intent of an action. - **Primary** — High emphasis for main actions like "Save" or "Confirm". Limit to one per screen. - **Secondary** — Medium emphasis for multiple actions of equal weight. -- **Tertiary** — High contrast with inverted background. +- **Tertiary** — Low emphasis with a muted background. +- **Inverse** — High contrast with inverted background. - **Negative** — Destructive actions that can't be undone. Use sparingly. ```jsx live @@ -28,6 +29,9 @@ Use variants to communicate the importance and intent of an action. + @@ -49,6 +53,9 @@ Use transparent buttons for supplementary actions with lower prominence. The con + diff --git a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json index 77ea5e689d..84ba30dce8 100644 --- a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/webMetadata.json b/apps/docs/docs/components/inputs/Combobox/webMetadata.json index 4db33d49b9..807152ff4b 100644 --- a/apps/docs/docs/components/inputs/Combobox/webMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx index de2eb7bdee..35c8fbf927 100644 --- a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onPress={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onPress={console.log} /> - ``` @@ -135,13 +122,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onPress={console.log} /> - ``` @@ -191,7 +171,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx index 4228b112f4..7f56da4a60 100644 --- a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onClick={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onClick={console.log} /> - ``` @@ -201,13 +188,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onClick={console.log} /> - ``` @@ -257,7 +237,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx live function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json index 6ef25ea309..a1d0bcb69c 100644 --- a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json @@ -28,7 +28,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json index 685965c943..1835a350d4 100644 --- a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json index c594a0a22a..d4fccd3eac 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json @@ -18,7 +18,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/Select/webMetadata.json b/apps/docs/docs/components/inputs/Select/webMetadata.json index 135c57e756..6a0a6c4799 100644 --- a/apps/docs/docs/components/inputs/Select/webMetadata.json +++ b/apps/docs/docs/components/inputs/Select/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json index db9d2c6ec2..9bc249be52 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json @@ -8,7 +8,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json index 59cda3ea44..bb8c0514a6 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json @@ -12,7 +12,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json index 036d3ca9a0..aff8fd59a2 100644 --- a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json index fc3dc6f6c9..54a937a146 100644 --- a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json index b8be5679bb..c61c0c0385 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json index f7088fafeb..f629c63e12 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json @@ -26,7 +26,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json index 2c8b9fc38d..4748bad616 100644 --- a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-gesture-handler", - "version": "^2.16.2" + "version": "2.28.0" } ] } diff --git a/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx new file mode 100644 index 0000000000..bf115dda45 --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/controls/Switch/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/_webStyles.mdx b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx new file mode 100644 index 0000000000..05ea45585d --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Switch } from '@coinbase/cds-web/controls'; + +import webStylesData from ':docgen/web/controls/Switch/styles-data'; + +export const StatefulSwitchPreview = ({ classNames }) => { + const [isChecked, setIsChecked] = useState(false); + +return ( + + setIsChecked(event.target.checked)} +> + Dark mode + +); }; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/index.mdx b/apps/docs/docs/components/inputs/Switch/index.mdx index cbf97aa2b6..98ac45f3c7 100644 --- a/apps/docs/docs/components/inputs/Switch/index.mdx +++ b/apps/docs/docs/components/inputs/Switch/index.mdx @@ -16,6 +16,8 @@ import webPropsToc from ':docgen/web/controls/Switch/toc-props'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; @@ -34,12 +36,16 @@ import mobileMetadata from './mobileMetadata.json'; } + webStyles={} webExamples={} mobilePropsTable={} + mobileStyles={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} mobilePropsToc={mobilePropsToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json index 8cbed53d2b..13f259ea12 100644 --- a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json +++ b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx index 09678b32e1..4d7fd8c347 100644 --- a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx +++ b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx @@ -4,6 +4,8 @@ Carousels are a great way to showcase a list of items in a compact and engaging By default, Carousels have navigation and pagination enabled. You can also add a title to the Carousel by setting `title` prop. +`paginationVariant` is deprecated. Carousel now defaults to dot pagination. Existing uses of `paginationVariant="pill"` still work during the deprecation window, but new usage should prefer the default pagination or a custom `PaginationComponent`. + You simply wrap each child in a `CarouselItem` component, and can optionally set the `width` prop to control the width of the item. You can also set the `styles` prop to control the styles of the carousel, such as the gap between items. @@ -31,7 +33,6 @@ function MyCarousel() { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -171,7 +171,6 @@ function ResponsiveSizingCarousel() { return ( + @@ -611,14 +603,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onPress={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -673,7 +665,7 @@ function CustomComponentsCarousel() { Earn staking rewards on ETH by holding it on Coinbase @@ -697,7 +689,7 @@ function CustomComponentsCarousel() { Chat with other devs in our Discord community @@ -721,7 +713,7 @@ function CustomComponentsCarousel() { Use code NOV60 when you sign up for Coinbase One @@ -745,7 +737,7 @@ function CustomComponentsCarousel() { Spend USDC to get rewards with our Visa® debit card @@ -779,7 +771,6 @@ You can use the `styles` props to customize different parts of the carousel. function CustomStylesCarousel() { return ( ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -184,7 +185,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -213,7 +214,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -242,7 +243,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -558,7 +559,6 @@ function SnapModeCarousel() { + @@ -859,14 +856,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -942,7 +939,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -967,7 +964,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -992,7 +989,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -1017,7 +1014,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -1053,7 +1050,6 @@ function CustomStylesCarousel() { return ( console.log('Page changed', activePageIndex)} onDragStart={() => console.log('Drag started')} onDragEnd={() => console.log('Drag ended')} diff --git a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json index 539db97fbf..1f97ba4c47 100644 --- a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json +++ b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Dropdown/webMetadata.json b/apps/docs/docs/components/layout/Dropdown/webMetadata.json index 45e64fc68e..de6d581a08 100644 --- a/apps/docs/docs/components/layout/Dropdown/webMetadata.json +++ b/apps/docs/docs/components/layout/Dropdown/webMetadata.json @@ -22,7 +22,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/Avatar/mobileMetadata.json b/apps/docs/docs/components/media/Avatar/mobileMetadata.json index 1e9ac28249..9fe43be2da 100644 --- a/apps/docs/docs/components/media/Avatar/mobileMetadata.json +++ b/apps/docs/docs/components/media/Avatar/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Avatar/webMetadata.json b/apps/docs/docs/components/media/Avatar/webMetadata.json index 8570ed2307..05b76303df 100644 --- a/apps/docs/docs/components/media/Avatar/webMetadata.json +++ b/apps/docs/docs/components/media/Avatar/webMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json index 3ad2f79f6f..f0ae9e9932 100644 --- a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json +++ b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json index f9eb2b2ea7..5717fcb92c 100644 --- a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json index a2eca6c388..1042016dd0 100644 --- a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json index a06f647034..ba0a1e2dc6 100644 --- a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json index 30aedc1c64..3b826ae4ab 100644 --- a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json +++ b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json index ff0b14bfe2..fd2274207f 100644 --- a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json index 109cd18957..5e7183cda7 100644 --- a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json index c5697db759..b646cdaa6c 100644 --- a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json index f8817f27ba..8b0e9e96bf 100644 --- a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json index 03c6bc3376..e7dc64ca93 100644 --- a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json index 16d4c6ef1a..77fe49f782 100644 --- a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json index d0c8956cf6..e12bc1ed1c 100644 --- a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json index ecc956fceb..a8ed09aedd 100644 --- a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json index f51f4917f8..df48967410 100644 --- a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json @@ -24,7 +24,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json index f0bbda8767..bbb8cfc8fe 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx index 91b6d29bbd..0fb4e7e314 100644 --- a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx @@ -385,9 +385,9 @@ function RenderEndExample() { {!isCollapsed && ( - + Help & Support - + )} @@ -448,7 +448,9 @@ function CustomStyles() { > - Help + + Help + )} @@ -568,7 +570,11 @@ function ApplicationShell() { > - {!isCollapsed && Settings} + {!isCollapsed && ( + + Settings + + )} - {!isCollapsed && Profile} + {!isCollapsed && ( + + Profile + + )} diff --git a/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx new file mode 100644 index 0000000000..76b8dd7c95 --- /dev/null +++ b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx @@ -0,0 +1,30 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { LogoMark } from '@coinbase/cds-web/icons'; +import { HStack } from '@coinbase/cds-web/layout'; +import { Sidebar, SidebarItem } from '@coinbase/cds-web/navigation'; + +import webStylesData from ':docgen/web/navigation/SidebarItem/styles-data'; + +## Explorer + + + {(classNames) => ( + + }> + undefined} + title="Home" + tooltipContent="Home" + /> + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/navigation/SidebarItem/index.mdx b/apps/docs/docs/components/navigation/SidebarItem/index.mdx index f8b94cc0ea..65388061e9 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/index.mdx +++ b/apps/docs/docs/components/navigation/SidebarItem/index.mdx @@ -14,14 +14,17 @@ import webPropsToc from ':docgen/web/navigation/SidebarItem/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import webMetadata from './webMetadata.json'; } + webStyles={} webExamples={} webExamplesToc={webExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json index 648e617b7a..ff3117cd23 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json @@ -17,7 +17,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json index 5fcaf9184d..bba58c545c 100644 --- a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json index af4c63704f..a19864c6e1 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json index 4b2b7ef553..1bb03cbb63 100644 --- a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json index 745a786e7b..dbf20bf601 100644 --- a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json index 98412d5d70..49a6d1fa82 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json @@ -29,7 +29,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index c0db513f49..c8fc565aa3 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json index 3fd09a8015..13af54de36 100644 --- a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json index dcb490e8f9..636878e6ab 100644 --- a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/webMetadata.json b/apps/docs/docs/components/navigation/Tour/webMetadata.json index 53c33ef98b..3cdd3102a9 100644 --- a/apps/docs/docs/components/navigation/Tour/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json index 989c9eeae0..acf6065693 100644 --- a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json +++ b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json @@ -7,7 +7,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/Calendar/webMetadata.json b/apps/docs/docs/components/other/Calendar/webMetadata.json index 1ac432167b..5d9f8f465a 100644 --- a/apps/docs/docs/components/other/Calendar/webMetadata.json +++ b/apps/docs/docs/components/other/Calendar/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json index 6a1739571d..c67219b293 100644 --- a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/webMetadata.json b/apps/docs/docs/components/other/DatePicker/webMetadata.json index c785ad8c75..bcf504b892 100644 --- a/apps/docs/docs/components/other/DatePicker/webMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DotCount/mobileMetadata.json b/apps/docs/docs/components/other/DotCount/mobileMetadata.json index 78d58b2e5c..bbef0f0284 100644 --- a/apps/docs/docs/components/other/DotCount/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotCount/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json index aed0ba53c3..f5077fee12 100644 --- a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx index 744a052539..72ae20c962 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx @@ -52,6 +52,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -80,7 +82,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx index a2033aae8a..e1ada240a6 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx @@ -46,11 +46,11 @@ theme.fontSize.display3; // "2.5rem" For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. ::: -## ThemeProvider CSS Variables +## CSS Variables CSS Variables are created for every value in the theme. -For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. +For best performance, prefer using CSS Variables instead of the `useTheme` hook whenever possible. ```jsx const theme = useTheme(); @@ -70,6 +70,23 @@ You can see all the CSS Variables for the `defaultTheme` below. +### CSS Variable inheritance + +When ThemeProviders are nested, the nested provider only sets CSS variables that differ from its parent. +Unchanged values are inherited through the DOM via normal CSS custom property inheritance. + +This optimization breaks when a ThemeProvider renders **outside** its parent's DOM tree — for example, inside a portal — because CSS inheritance requires DOM ancestry. In these cases, use the `isolated` prop to ensure the ThemeProvider writes all CSS variables: + +```tsx + + {/* All CSS variables are written, regardless of the parent theme */} + +``` + +:::tip +CDS overlay components (Modal, Toast, Alert, etc.) handle this automatically via [PortalProvider](/components/overlay/PortalProvider). You only need the `isolated` prop when rendering a ThemeProvider inside a custom portal that is not managed by CDS. +::: + ## ThemeProvider classnames The ThemeProvider renders with CSS classnames based on the `activeColorScheme` and the theme's `id`. @@ -95,6 +112,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -123,7 +142,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/overlay/Alert/webMetadata.json b/apps/docs/docs/components/overlay/Alert/webMetadata.json index c89a519b50..0d04f735ea 100644 --- a/apps/docs/docs/components/overlay/Alert/webMetadata.json +++ b/apps/docs/docs/components/overlay/Alert/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json index 48a8300764..436ca5e1ec 100644 --- a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json index 9fb8bfb472..b1341fbd57 100644 --- a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json index 94605e2da2..32dfbe0dc5 100644 --- a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Modal/webMetadata.json b/apps/docs/docs/components/overlay/Modal/webMetadata.json index 6be86f4db3..0f641bc4f1 100644 --- a/apps/docs/docs/components/overlay/Modal/webMetadata.json +++ b/apps/docs/docs/components/overlay/Modal/webMetadata.json @@ -37,7 +37,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/PopoverPanel/webMetadata.json b/apps/docs/docs/components/overlay/PopoverPanel/webMetadata.json index 480a8cb920..0fe6c6205b 100644 --- a/apps/docs/docs/components/overlay/PopoverPanel/webMetadata.json +++ b/apps/docs/docs/components/overlay/PopoverPanel/webMetadata.json @@ -24,7 +24,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx index 5c64fa8cf2..53dbac6446 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your mobile application to manage overlay components: +Render PortalProvider once near the root of your application, to manage overlay components: ```tsx function App() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx index 48b39e28eb..673d79f8f5 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your application to manage overlay components: +Render PortalProvider once near the root of your application: ```tsx live function Example() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/index.mdx b/apps/docs/docs/components/overlay/PortalProvider/index.mdx index 32aa69a7e4..43ba352540 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/index.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/index.mdx @@ -28,7 +28,7 @@ import mobileMetadata from './mobileMetadata.json'; title="PortalProvider" webMetadata={webMetadata} mobileMetadata={mobileMetadata} - description="The PortalProvider component manages the rendering of portals for modals, toasts, alerts, and tooltips. It provides a centralized way to handle overlay components in your application." + description="A required root-level provider that enables CDS overlay components (Modal, Toast, Alert, Tooltip, Tray). Must be rendered once near the root of your application, alongside ThemeProvider." /> {item} handleDelete(index)} accessibilityLabel={`Delete ${item}`} /> diff --git a/apps/docs/docs/components/overlay/Toast/webMetadata.json b/apps/docs/docs/components/overlay/Toast/webMetadata.json index 690f61dd3f..40cc051bb9 100644 --- a/apps/docs/docs/components/overlay/Toast/webMetadata.json +++ b/apps/docs/docs/components/overlay/Toast/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json index 1355f725a8..d1d828744a 100644 --- a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json index 2e1051b2e4..034502ee0c 100644 --- a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/webMetadata.json b/apps/docs/docs/components/overlay/Tray/webMetadata.json index 0d3166435d..7315ea3ee1 100644 --- a/apps/docs/docs/components/overlay/Tray/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx index bb063fbb78..cf1a325cbc 100644 --- a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx @@ -188,7 +188,8 @@ React Native flattens nested Text into a string and cannot focus internal links ```jsx import { AccessibilityInfo, Linking } from 'react-native'; - Consider a case where you have a block of text with an inline link.{' '} Like so. You may want to write your code like this. -; +; ``` ### Multiple nested links diff --git a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx index 0e5a7fd240..484251c9e4 100644 --- a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx @@ -57,34 +57,25 @@ All text components support a few numeric typography styles, overflow, text tran ### A11y -On the web, there are different HTML elements to wrap texts with to communicated semantic meanings of the strings. Therefore, CDS does not make any assumptions about the semantic of the text but ask developers to choose the approriate semantic HTML element via the `as` prop. +On mobile, `Text` automatically sets `accessibilityRole="header"` for display and title font variants (`display1`, `display2`, `display3`, `title1`, `title2`). This ensures screen readers correctly identify these elements as headings without requiring any additional props. ```jsx -Display +// accessibilityRole="header" is applied automatically +Page Title -// If we want large text but not as the page title -Display +// Other font variants do not receive a default accessibilityRole +Regular body text ``` -### Headings +You can override the default `accessibilityRole` if needed: -Headings help users understand the hierarchical page organization. All pages on the web should at least have a `

` level heading providing the title or summary of the page. Screen readers users prefer that only the document title be `

` on a page. Headings should NOT be used inside tables header elements (``). - -When using headings, it is confusing to screen reader users to skip heading levels to be more specific (ex. do not go from `

` to `

`). However, it is permissible to use a higher heading level after a lower heading level, i.e. from `

`to`

`, if the outline of the page calls for it. - -One common misconception is that headings for a web app have consistent typography across different pages. That is not an accessibility requirement or a design guideline that our product designers follow. Therefore, based on the content layouts, product engineers should determine the approriate semantic tags to use for each string and choose the proper heading element when the texts convey hierarchical content information. - -Yale has a detailed [web accessibility article](https://usability.yale.edu/web-accessibility/articles/headings#:~:text=One%20of%20the%20most%20common,Do%20not%20overuse%20headings) about how to use headings if you want to learn more. - -In a nutshell, you can reference the following for the most common text semantics. +```jsx +// Override to remove the default header role +Decorative Title -- `h1` for page title (exactly one per page) -- `h2`-`h4` for hierarchical section headings (CDS does not foresee the need for heading level 5 or 6 in Coinbase products). -- `p` for paragraphs of text with default block display. It can be wrapped inside `blockquote`, `li`, or `label` elements for additional semantics. -- `li` for bullet points in a list. -- `time`, `abbr`, `sup`, `kbd`, etc, for granular semantics. -- `pre` and `code` for preformatted code blocks. -- `span` when no semantics are required (within buttons for example) and it also has default inline display. +// Explicitly set a different role +Summary Section +``` ### With Links diff --git a/apps/docs/docs/getting-started/installation/_mobileContent.mdx b/apps/docs/docs/getting-started/installation/_mobileContent.mdx index 60ab85c711..05d0067062 100644 --- a/apps/docs/docs/getting-started/installation/_mobileContent.mdx +++ b/apps/docs/docs/getting-started/installation/_mobileContent.mdx @@ -33,18 +33,24 @@ For React Native projects, ensure you have set up your environment for React Nat ## Getting started -### 1. Render a ThemeProvider +### 1. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: + +- **ThemeProvider** — applies the CDS theme and color scheme +- **PortalProvider** — manages the registry of active overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider } from '@coinbase/cds-mobile/system'; +import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); diff --git a/apps/docs/docs/getting-started/installation/_webContent.mdx b/apps/docs/docs/getting-started/installation/_webContent.mdx index d0e5dac40a..fdeb0338b4 100644 --- a/apps/docs/docs/getting-started/installation/_webContent.mdx +++ b/apps/docs/docs/getting-started/installation/_webContent.mdx @@ -49,21 +49,26 @@ import '@coinbase/cds-web/defaultFontStyles'; -### 2. Render a ThemeProvider and MediaQueryProvider +### 2. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: -Render a MediaQueryProvider for components that use the `useMediaQuery` hook. +- **ThemeProvider** — applies the CDS theme and color scheme +- **MediaQueryProvider** — prevents issues with `window.matchMedia()` in SSR environments ([read more →](/components/other/MediaQueryProvider#server-side-rendering)) +- **PortalProvider** — creates the DOM containers required by overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); @@ -104,6 +109,7 @@ import '@coinbase/cds-web/defaultFontStyles'; import '@coinbase/cds-web/globalStyles'; import { createRoot } from 'react-dom/client'; import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; @@ -112,7 +118,9 @@ const root = createRoot(document.getElementById('root')); root.render( - + + + , ); diff --git a/apps/docs/docs/hooks/useMergeRefs/_api.mdx b/apps/docs/docs/hooks/useMergeRefs/_api.mdx index 0266f16ead..ef38176406 100644 --- a/apps/docs/docs/hooks/useMergeRefs/_api.mdx +++ b/apps/docs/docs/hooks/useMergeRefs/_api.mdx @@ -5,10 +5,10 @@ import { MDXArticle } from '@site/src/components/page/MDXArticle'; The `useMergeRefs` hook accepts a spread of refs as its parameter: -- `...refs: (React.MutableRefObject | React.LegacyRef | undefined | null)[]` - An array of refs to merge. Can include: - - `MutableRefObject` - Object-based refs created with `useRef` - - `LegacyRef` - Function-based refs or string refs (legacy) - - `undefined` or `null` - Optional refs that might not be provided +- `...refs: (React.Ref | undefined)[]` - An array of refs to merge. Can include: + - `RefObject` - Object-based refs created with `useRef` / `createRef` + - `RefCallback` - Function refs + - `undefined` - Optional refs that might not be provided diff --git a/apps/docs/docs/hooks/useMergeRefs/metadata.json b/apps/docs/docs/hooks/useMergeRefs/metadata.json index 22d542ff63..f958e8363a 100644 --- a/apps/docs/docs/hooks/useMergeRefs/metadata.json +++ b/apps/docs/docs/hooks/useMergeRefs/metadata.json @@ -1,5 +1,5 @@ { - "import": "import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'", + "import": "import { useMergeRefs } from '@coinbase/cds-common/utils/mergeRefs'", "source": "https://github.com/coinbase/cds/blob/master/packages/common/src/hooks/useMergeRefs.ts", "description": "Combines multiple refs into a single ref callback, allowing a component to work with multiple ref instances simultaneously. Useful when you need to combine refs from different sources, such as forwarded refs and internal component state." } diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 63ce097a96..7e75ab7e7c 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -6,9 +6,7 @@ import commonPackageJson from '../../packages/common/package.json'; import iconsPackageJson from '../../packages/icons/package.json'; import illustrationsPackageJson from '../../packages/illustrations/package.json'; import mobilePackageJson from '../../packages/mobile/package.json'; -import mobileVisualizationPackageJson from '../../packages/mobile-visualization/package.json'; import webPackageJson from '../../packages/web/package.json'; -import webVisualizationPackageJson from '../../packages/web-visualization/package.json'; import docgenConfig from './docgen.config'; @@ -50,15 +48,7 @@ const webpackPlugin = () => { ), '@coinbase/cds-utils': path.resolve(__dirname, '../../packages/utils/src'), '@coinbase/cds-mobile': path.resolve(__dirname, '../../packages/mobile/src'), - '@coinbase/cds-mobile-visualization': path.resolve( - __dirname, - '../../packages/mobile-visualization/src', - ), '@coinbase/cds-web': path.resolve(__dirname, '../../packages/web/src'), - '@coinbase/cds-web-visualization': path.resolve( - __dirname, - '../../packages/web-visualization/src', - ), }), }, }, @@ -115,8 +105,6 @@ const config: Config = { cdsCommonVersion: commonPackageJson.version, cdsIconsVersion: iconsPackageJson.version, cdsIllustrationsVersion: illustrationsPackageJson.version, - cdsMobileVisualizationVersion: mobileVisualizationPackageJson.version, - cdsWebVisualizationVersion: webVisualizationPackageJson.version, }, // Even if you don't use internationalization, you can use this field to set diff --git a/apps/docs/package.json b/apps/docs/package.json index 78e8048584..e64cc54139 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -21,9 +21,7 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", "@coinbase/docusaurus-plugin-docgen": "workspace:^", "@coinbase/docusaurus-plugin-kbar": "workspace:^", "@coinbase/docusaurus-plugin-llm-dev-server": "workspace:^", @@ -56,16 +54,16 @@ "lz-string": "^1.5.0", "prettier": "^3.6.2", "prism-react-renderer": "^2.4.1", - "react": "^18.3.1", + "react": "19.1.2", "react-colorful": "^5.6.1", - "react-dom": "^18.3.1", + "react-dom": "19.1.2", "react-live": "^4.1.8", "three": "0.177.0" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/module-type-aliases": "~3.7.0", "@docusaurus/tsconfig": "~3.7.0", @@ -75,8 +73,8 @@ "@linaria/webpack-loader": "^3.0.0-beta.22", "@types/culori": "^4", "@types/lz-string": "^1.5.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", "@types/three": "0.177.0", "babel-loader": "^10.0.0", "css-loader": "^7.1.2", diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 69b6e4af3b..30c5041e80 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -939,26 +939,6 @@ const sidebars: SidebarsConfig = { }, }, }, - { - type: 'link', - href: 'https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/CHANGELOG.md', - label: '@coinbase/cds-mobile-visualization', - customProps: { - kbar: { - keywords: 'changelog', - }, - }, - }, - { - type: 'link', - href: 'https://github.com/coinbase/cds/blob/master/packages/web-visualization/CHANGELOG.md', - label: '@coinbase/cds-web-visualization', - customProps: { - kbar: { - keywords: 'changelog', - }, - }, - }, ], }, ], diff --git a/apps/docs/src/components/ButtonLink/index.tsx b/apps/docs/src/components/ButtonLink/index.tsx index c1f11fd01b..960b28b89d 100644 --- a/apps/docs/src/components/ButtonLink/index.tsx +++ b/apps/docs/src/components/ButtonLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Button, type ButtonProps } from '@coinbase/cds-web/buttons'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/components/FooterLink/index.tsx b/apps/docs/src/components/FooterLink/index.tsx index adaacdfac4..d35c99daf2 100644 --- a/apps/docs/src/components/FooterLink/index.tsx +++ b/apps/docs/src/components/FooterLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Text, type TextDefaultElement, type TextProps } from '@coinbase/cds-web/typography/Text'; import isInternalUrl from '@docusaurus/isInternalUrl'; import Link, { type Props } from '@docusaurus/Link'; diff --git a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx index 2cbe2708f3..8978b282d9 100644 --- a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx +++ b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx @@ -66,10 +66,10 @@ export const HeroCell = ({ diff --git a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx index 0e2a272823..76b638e100 100644 --- a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx +++ b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx @@ -9,8 +9,8 @@ export type QuickStartLinkProps = { title: string; description: string; link: { label: string; to: string } | { label: string; href: string }; - BannerComponentLight: React.ComponentType>; - BannerComponentDark: React.ComponentType>; + BannerComponentLight: React.ComponentType<{ width?: string | number; height?: string | number }>; + BannerComponentDark: React.ComponentType<{ width?: string | number; height?: string | number }>; }; const cardTitleFontConfig = { base: 'title4', desktop: 'title2' } as const; diff --git a/apps/docs/src/components/kbar/KBarAnimator.tsx b/apps/docs/src/components/kbar/KBarAnimator.tsx index 1d0d78a6f5..aa2a8f67b5 100644 --- a/apps/docs/src/components/kbar/KBarAnimator.tsx +++ b/apps/docs/src/components/kbar/KBarAnimator.tsx @@ -30,7 +30,7 @@ const KBarAnimator = memo(function KBarAnimator({ children }: { children: React. const exitMs = options?.animations?.exitMs ?? 0; // Height animation - const previousHeight = useRef(); + const previousHeight = useRef(undefined); useEffect(() => { // Only animate if we're actually showing if (visualState === VisualState.showing) { diff --git a/apps/docs/src/components/page/ColorPairingTool/PlaygroundContent.tsx b/apps/docs/src/components/page/ColorPairingTool/PlaygroundContent.tsx index 432027b8ae..7ad4f3107d 100644 --- a/apps/docs/src/components/page/ColorPairingTool/PlaygroundContent.tsx +++ b/apps/docs/src/components/page/ColorPairingTool/PlaygroundContent.tsx @@ -6,7 +6,7 @@ import { Card, MessagingCard } from '@coinbase/cds-web/cards'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Tag } from '@coinbase/cds-web/tag'; import { Text } from '@coinbase/cds-web/typography'; -import { LineChart, Scrubber, SolidLine } from '@coinbase/cds-web-visualization'; +import { LineChart, Scrubber, SolidLine } from '@coinbase/cds-web/visualizations/chart'; import CheckerboardSvg from './checkerboard.svg'; import { aaTextColor } from './colorUtils'; diff --git a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx index dbec407807..b7fdd65977 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx @@ -9,7 +9,7 @@ import { Link, type LinkBaseProps } from '@coinbase/cds-web/typography/Link'; export type ModalLinkProps = { children: string; content: React.ReactElement; - modalBodyRef?: React.RefObject; + modalBodyRef?: React.RefObject; modalBodyProps?: Omit; title?: string; } & Omit; diff --git a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx index f41645312c..7ca9bc9bb4 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx @@ -22,7 +22,7 @@ function ParentTypesTable({ sharedParentTypes, props, scrollContainerRef, -}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { +}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { const [searchValue, setSearchValue] = useState(''); const filteredProps = useMemo( () => diff --git a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx index c7945480b2..a3bdde2817 100644 --- a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx +++ b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx @@ -49,7 +49,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const ComponentTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/HookTabsContainer/index.tsx b/apps/docs/src/components/page/HookTabsContainer/index.tsx index ac0273052b..df2a2cbc95 100644 --- a/apps/docs/src/components/page/HookTabsContainer/index.tsx +++ b/apps/docs/src/components/page/HookTabsContainer/index.tsx @@ -43,7 +43,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const HookTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/JSONCodeBlock/index.tsx b/apps/docs/src/components/page/JSONCodeBlock/index.tsx index f477eb4ea6..80eae8e223 100644 --- a/apps/docs/src/components/page/JSONCodeBlock/index.tsx +++ b/apps/docs/src/components/page/JSONCodeBlock/index.tsx @@ -5,8 +5,11 @@ import styles from './styles.module.css'; export const JSONCodeBlock = ({ json }: { json: Serializable }) => { return ( - - {JSON.stringify(json, null, 2)} - + <> + + {JSON.stringify(json, null, 2)} + +
+ ); }; diff --git a/apps/docs/src/components/page/ShareablePlayground/index.tsx b/apps/docs/src/components/page/ShareablePlayground/index.tsx index b459d94057..a7bb73b72d 100644 --- a/apps/docs/src/components/page/ShareablePlayground/index.tsx +++ b/apps/docs/src/components/page/ShareablePlayground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/components/page/SheetTabs/index.tsx b/apps/docs/src/components/page/SheetTabs/index.tsx index 1d778b0cc0..c6cfd46b80 100644 --- a/apps/docs/src/components/page/SheetTabs/index.tsx +++ b/apps/docs/src/components/page/SheetTabs/index.tsx @@ -43,8 +43,8 @@ export const SheetTabs = ( props: Omit, ) => ( ); diff --git a/apps/docs/src/components/page/VersionLabel/index.tsx b/apps/docs/src/components/page/VersionLabel/index.tsx index 87dcb579ef..295f4f7c29 100644 --- a/apps/docs/src/components/page/VersionLabel/index.tsx +++ b/apps/docs/src/components/page/VersionLabel/index.tsx @@ -30,12 +30,6 @@ export const VersionLabel = ({ case '@coinbase/cds-illustrations': version = versions.cdsIllustrationsVersion; break; - case '@coinbase/cds-web-visualization': - version = versions.cdsWebVisualizationVersion; - break; - case '@coinbase/cds-mobile-visualization': - version = versions.cdsMobileVisualizationVersion; - break; default: throw new Error(`VersionLabel received invalid "packageName" prop: ${packageName}`); } diff --git a/apps/docs/src/hooks/useCDSVersions.ts b/apps/docs/src/hooks/useCDSVersions.ts index 6bdfe0bd02..2c8d61a8da 100644 --- a/apps/docs/src/hooks/useCDSVersions.ts +++ b/apps/docs/src/hooks/useCDSVersions.ts @@ -3,13 +3,8 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; /** Returns the CDS package versions which are defined in the docusaurus.config.ts file. */ export const useCDSVersions = () => { const { siteConfig } = useDocusaurusContext(); - const { - cdsCommonVersion, - cdsIconsVersion, - cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, - } = siteConfig.customFields ?? {}; + const { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion } = + siteConfig.customFields ?? {}; if (typeof cdsCommonVersion !== 'string') throw Error( @@ -23,20 +18,10 @@ export const useCDSVersions = () => { throw Error( 'The "cdsIllustrationsVersion" string is not defined in docusaurus.config.ts "customFields"', ); - if (typeof cdsMobileVisualizationVersion !== 'string') - throw Error( - 'The "cdsMobileVisualizationVersion" string is not defined in docusaurus.config.ts "customFields"', - ); - if (typeof cdsWebVisualizationVersion !== 'string') - throw Error( - 'The "cdsWebVisualizationVersion" string is not defined in docusaurus.config.ts "customFields"', - ); return { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, }; }; diff --git a/apps/docs/src/theme/DocItem/Layout/index.tsx b/apps/docs/src/theme/DocItem/Layout/index.tsx index 2d673b5dc5..3c4740d6d9 100644 --- a/apps/docs/src/theme/DocItem/Layout/index.tsx +++ b/apps/docs/src/theme/DocItem/Layout/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { DocFrontMatter } from '@docusaurus/plugin-content-docs'; import { useDoc } from '@docusaurus/plugin-content-docs/client'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx index 030ceee955..08882be91d 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { HStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/DocRoot/Layout/Main'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx index c0afc87f1d..2fc394fef1 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocRoot/Layout/Sidebar/ExpandButton'; import IconArrow from '@theme/Icon/Arrow'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx index 9f02321b23..762a91b61b 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { type ReactNode, useCallback, useState } from 'react'; +import React, { type JSX, type ReactNode, useCallback, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import { useLocation } from '@docusaurus/router'; diff --git a/apps/docs/src/theme/DocRoot/Layout/index.tsx b/apps/docs/src/theme/DocRoot/Layout/index.tsx index 298c4a2c14..d8f86881e6 100644 --- a/apps/docs/src/theme/DocRoot/Layout/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import BackToTopButton from '@theme/BackToTopButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx index 2d4404764b..e33abd8ec9 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocSidebar/Desktop/CollapseButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx index aeea018217..80e9120374 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { VStack } from '@coinbase/cds-web/layout'; import { ThemeClassNames } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/DocSidebar/index.tsx b/apps/docs/src/theme/DocSidebar/index.tsx index 6cc90ff6c3..1d00f852ba 100644 --- a/apps/docs/src/theme/DocSidebar/index.tsx +++ b/apps/docs/src/theme/DocSidebar/index.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { PropSidebarItem } from '@docusaurus/plugin-content-docs'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; import type { Props } from '@theme/DocSidebar'; import DocSidebarDesktop from '@theme/DocSidebar/Desktop'; import DocSidebarMobile from '@theme/DocSidebar/Mobile'; -export default function DocSidebar(props: Props): JSX.Element { +export default function DocSidebar({ sidebar, ...props }: Props): JSX.Element { const windowSize = useWindowSizeWithBreakpointOverride(); const filterItems = (items: PropSidebarItem[] = []): PropSidebarItem[] => { @@ -13,7 +13,7 @@ export default function DocSidebar(props: Props): JSX.Element { }; // Filter the sidebar items - const filteredSidebar = filterItems([...props.sidebar]); + const filteredSidebar = filterItems([...sidebar]); // Desktop sidebar visible on hydration: need SSR rendering const shouldRenderSidebarDesktop = windowSize === 'desktop' || windowSize === 'ssr'; @@ -23,8 +23,8 @@ export default function DocSidebar(props: Props): JSX.Element { return ( <> - {shouldRenderSidebarDesktop && } - {shouldRenderSidebarMobile && } + {shouldRenderSidebarDesktop && } + {shouldRenderSidebarMobile && } ); } diff --git a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx index 8a2ae4f429..6a1423b0ed 100644 --- a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { type JSX, useCallback, useEffect, useMemo } from 'react'; import type { IconName } from '@coinbase/cds-common/types'; import { cx } from '@coinbase/cds-web'; import { Collapsible } from '@coinbase/cds-web/collapsible'; diff --git a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx index 8418d584b0..3eb57c8a0c 100644 --- a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { ThemeClassNames } from '@docusaurus/theme-common'; import type { Props } from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx index 7dbd328c60..dcc123b256 100644 --- a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/theme/DocSidebarItem/index.tsx b/apps/docs/src/theme/DocSidebarItem/index.tsx index 13602a3add..564a375f02 100644 --- a/apps/docs/src/theme/DocSidebarItem/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { Props } from '@theme/DocSidebarItem'; import DocSidebarItemCategory from '@theme/DocSidebarItem/Category'; import DocSidebarItemHtml from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/Footer/index.tsx b/apps/docs/src/theme/Footer/index.tsx index b9f2383dd2..d870727d7a 100644 --- a/apps/docs/src/theme/Footer/index.tsx +++ b/apps/docs/src/theme/Footer/index.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import type { FooterLinkItem } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/Heading/index.tsx b/apps/docs/src/theme/Heading/index.tsx index 27624ceed4..6f11e8370d 100644 --- a/apps/docs/src/theme/Heading/index.tsx +++ b/apps/docs/src/theme/Heading/index.tsx @@ -15,7 +15,7 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { } = useThemeConfig(); // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { - return ; + return ; } brokenLinks.collectAnchor(id); @@ -33,13 +33,13 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { return ( {props.children} diff --git a/apps/docs/src/theme/Layout/Provider/index.tsx b/apps/docs/src/theme/Layout/Provider/index.tsx index 4e5b7edb03..4a69c5a256 100644 --- a/apps/docs/src/theme/Layout/Provider/index.tsx +++ b/apps/docs/src/theme/Layout/Provider/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultFontStyles } from '@coinbase/cds-web/styles/defaultFont'; diff --git a/apps/docs/src/theme/Layout/index.tsx b/apps/docs/src/theme/Layout/index.tsx index 2359cd1615..ca156a67da 100644 --- a/apps/docs/src/theme/Layout/index.tsx +++ b/apps/docs/src/theme/Layout/index.tsx @@ -1,6 +1,6 @@ import '@coinbase/cds-icons/fonts/web/icon-font.css'; -import { useCallback } from 'react'; +import { type JSX, useCallback } from 'react'; import { cx } from '@coinbase/cds-web'; import type { FallbackParams } from '@docusaurus/ErrorBoundary'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; diff --git a/apps/docs/src/theme/Navbar/Content/index.tsx b/apps/docs/src/theme/Navbar/Content/index.tsx index cd955bb229..bb938da0ef 100644 --- a/apps/docs/src/theme/Navbar/Content/index.tsx +++ b/apps/docs/src/theme/Navbar/Content/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { type JSX, useMemo, useRef } from 'react'; import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { HStack } from '@coinbase/cds-web/layout/HStack'; import { Tooltip } from '@coinbase/cds-web/overlays/tooltip/Tooltip'; @@ -20,13 +20,7 @@ function useNavbarItems() { export default function NavbarContent(): JSX.Element { const windowSize = useWindowSizeWithBreakpointOverride(); - const { - cdsCommonVersion, - cdsIconsVersion, - cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, - } = useCDSVersions(); + const { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion } = useCDSVersions(); const items = useNavbarItems(); const linkItems = useMemo( @@ -61,8 +55,6 @@ export default function NavbarContent(): JSX.Element { @coinbase/cds-web@{cdsCommonVersion} @coinbase/cds-icons@{cdsIconsVersion} @coinbase/cds-illustrations@{cdsIllustrationsVersion} - @coinbase/cds-mobile-visualization@{cdsMobileVisualizationVersion} - @coinbase/cds-web-visualization@{cdsWebVisualizationVersion}
); diff --git a/apps/docs/src/theme/Navbar/Layout/index.tsx b/apps/docs/src/theme/Navbar/Layout/index.tsx index 8270f353e3..56a1cffabd 100644 --- a/apps/docs/src/theme/Navbar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/Layout/index.tsx @@ -10,7 +10,7 @@ import { useWindowSizeWithBreakpointOverride } from '../../../utils/useWindowSiz import styles from './styles.module.css'; -function NavbarBackdrop(props: ComponentProps<'div'>) { +function NavbarBackdrop({ className, ...props }: ComponentProps<'div'>) { const mobileSidebar = useNavbarMobileSidebar(); const windowSize = useWindowSizeWithBreakpointOverride(); if (mobileSidebar.disabled || windowSize !== 'mobile') { @@ -18,10 +18,10 @@ function NavbarBackdrop(props: ComponentProps<'div'>) { } return (
); } diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx index b595cbd0ed..9095e0bd5e 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { HStack } from '@coinbase/cds-web/layout'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx index 0b137e1295..79428c1219 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/Navbar/MobileSidebar/Layout'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx index 49990f1468..b08dbecabd 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Icon } from '@coinbase/cds-web/icons/Icon'; import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx index 2a799d7d13..7560695f31 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { translate } from '@docusaurus/Translate'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx index ac0a3d8956..8aba5f2669 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { type JSX, useCallback, useEffect, useRef } from 'react'; import { FocusTrap } from '@coinbase/cds-web/overlays/FocusTrap'; import { useLockBodyScroll, useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; diff --git a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx index 1db8fbf5b9..25c0bff7d7 100644 --- a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx +++ b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Box } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Playground/index.tsx b/apps/docs/src/theme/Playground/index.tsx index a14d4d04f1..0a0028900f 100644 --- a/apps/docs/src/theme/Playground/index.tsx +++ b/apps/docs/src/theme/Playground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider, withLive } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/theme/Playground/sandbox/templateFiles.ts b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts index ae58ee35ad..632337b17f 100644 --- a/apps/docs/src/theme/Playground/sandbox/templateFiles.ts +++ b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts @@ -35,7 +35,6 @@ export const PACKAGE_JSON = JSON.stringify( '@coinbase/cds-illustrations': 'latest', '@coinbase/cds-lottie-files': 'latest', '@coinbase/cds-utils': 'latest', - '@coinbase/cds-web-visualization': 'latest', 'framer-motion': '^10.18.0', }, devDependencies: { diff --git a/apps/docs/src/theme/ReactLiveScope/index.tsx b/apps/docs/src/theme/ReactLiveScope/index.tsx index 36d184ac2e..63674fb219 100644 --- a/apps/docs/src/theme/ReactLiveScope/index.tsx +++ b/apps/docs/src/theme/ReactLiveScope/index.tsx @@ -88,8 +88,8 @@ import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import * as CDSTour from '@coinbase/cds-web/tour'; import * as CDSTypography from '@coinbase/cds-web/typography'; import * as CDSVisualizations from '@coinbase/cds-web/visualizations'; -import * as CDSChartComponents from '@coinbase/cds-web-visualization/chart'; -import * as CDSSparklineComponents from '@coinbase/cds-web-visualization/sparkline'; +import * as CDSChartComponents from '@coinbase/cds-web/visualizations/chart'; +import * as CDSSparklineComponents from '@coinbase/cds-web/visualizations/sparkline'; import * as framerMotion from 'framer-motion'; export type ImportMapEntry = { @@ -122,8 +122,8 @@ const namespaceRegistrations: [Record, string][] = [ [CDSDates, '@coinbase/cds-web/dates'], [CDSNumbers, '@coinbase/cds-web/numbers'], [CDSVisualizations, '@coinbase/cds-web/visualizations'], - [CDSChartComponents, '@coinbase/cds-web-visualization/chart'], - [CDSSparklineComponents, '@coinbase/cds-web-visualization/sparkline'], + [CDSChartComponents, '@coinbase/cds-web/visualizations/chart'], + [CDSSparklineComponents, '@coinbase/cds-web/visualizations/sparkline'], [StepperComponents, '@coinbase/cds-web/stepper'], [ContentCardComponents, '@coinbase/cds-web/cards/ContentCard'], [CDSDataAssets, '@coinbase/cds-common/internal/data/assets'], diff --git a/apps/docs/src/utils/useIsSticky.ts b/apps/docs/src/utils/useIsSticky.ts index 4581b19a1a..94a4a3757c 100644 --- a/apps/docs/src/utils/useIsSticky.ts +++ b/apps/docs/src/utils/useIsSticky.ts @@ -13,12 +13,12 @@ type UseStickyOptions = { * Optional ref to a container element. If provided, the sticky behavior will be relative * to this container instead of the viewport. */ - containerRef?: RefObject; + containerRef?: RefObject; }; type UseStickyResult = { /** Ref to attach to the element that should become sticky */ - elementRef: RefObject; + elementRef: RefObject; /** Whether the element is currently in "sticky" state */ isSticky: boolean; }; @@ -38,7 +38,7 @@ type UseStickyResult = { export function useIsSticky(options: UseStickyOptions = {}): UseStickyResult { const { top = 0, containerRef } = options; - const elementRef = useRef(null); + const elementRef = useRef(null); const [isSticky, setIsSticky] = useState(false); useEffect(() => { diff --git a/apps/docs/src/utils/useThrottledValue.ts b/apps/docs/src/utils/useThrottledValue.ts index 6697e746a2..dcd32bed76 100644 --- a/apps/docs/src/utils/useThrottledValue.ts +++ b/apps/docs/src/utils/useThrottledValue.ts @@ -18,7 +18,7 @@ export const useThrottledValue = (value: T, delay: number) => { const lastExecutedAt = useRef(0); // Ref to store the timeout ID that ensures the final synchronization of the throttled value after the value has not changed for the delay period - const throttleTimeoutIdRef = useRef>(); + const throttleTimeoutIdRef = useRef>(undefined); // updates the throttled value and schedules a final update after the delay period if needed const updateThrottledValue = useCallback( diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index db6c554e99..fbf4dd99c9 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../packages/web" }, - { - "path": "../../packages/web-visualization" - }, { "path": "../../libs/docusaurus-plugin-kbar" }, diff --git a/apps/docs/utils/generateComponentPeerDeps.ts b/apps/docs/utils/generateComponentPeerDeps.ts index 4a009a0815..ef0c88459d 100644 --- a/apps/docs/utils/generateComponentPeerDeps.ts +++ b/apps/docs/utils/generateComponentPeerDeps.ts @@ -13,11 +13,6 @@ type PackageConfig = { const PACKAGES: PackageConfig[] = [ { packageName: '@coinbase/cds-web', packageDir: 'packages/web' }, { packageName: '@coinbase/cds-mobile', packageDir: 'packages/mobile' }, - { packageName: '@coinbase/cds-web-visualization', packageDir: 'packages/web-visualization' }, - { - packageName: '@coinbase/cds-mobile-visualization', - packageDir: 'packages/mobile-visualization', - }, ]; /** diff --git a/apps/expo-app/.gitignore b/apps/expo-app/.gitignore new file mode 100644 index 0000000000..66cc3840a6 --- /dev/null +++ b/apps/expo-app/.gitignore @@ -0,0 +1,49 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +dts/ +*.d.ts +*.d.ts.map + +# generated native folders +/ios +/android + +# generated build artifacts (xcodebuild/gradle output, not committed) +builds/ +# extracted prebuilt .app directories (recreated by patch-bundle from the committed tarballs) +prebuilds/**/*.app diff --git a/apps/mobile-app/src/App.tsx b/apps/expo-app/App.tsx similarity index 69% rename from apps/mobile-app/src/App.tsx rename to apps/expo-app/App.tsx index 901d982d8b..9ba7f3ebc8 100644 --- a/apps/mobile-app/src/App.tsx +++ b/apps/expo-app/App.tsx @@ -1,5 +1,6 @@ -import React, { memo, StrictMode, useCallback, useMemo, useState } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import { Platform } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import type { ColorScheme } from '@coinbase/cds-common/core/theme'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; @@ -7,14 +8,14 @@ import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; import { StatusBar } from '@coinbase/cds-mobile/system/StatusBar'; import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; -import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization/chart'; -import { Playground } from '@coinbase/ui-mobile-playground'; +import { ChartBridgeProvider } from '@coinbase/cds-mobile/visualizations/chart'; import { CommonActions, NavigationContainer } from '@react-navigation/native'; import * as Linking from 'expo-linking'; import * as SplashScreen from 'expo-splash-screen'; -import { useFonts } from './hooks/useFonts'; -import { routes as codegenRoutes } from './routes'; +import { useFonts } from './src/hooks/useFonts'; +import { Playground } from './src/playground'; +import { routes as codegenRoutes } from './src/routes'; const linking = { prefixes: [Linking.createURL('/')], @@ -32,36 +33,26 @@ const linking = { CommonActions.reset({ index: 1, routes: [{ name: 'DebugExamples' }, ...state.routes] }), }; -// this code allows the use of toLocaleString() on Android if (Platform.OS === 'android') { require('intl'); require('intl/locale-data/jsonp/en-US'); } +const gestureHandlerStyle = { flex: 1 }; + const CdsSafeAreaProvider: React.FC> = memo(({ children }) => { const theme = useTheme(); const style = useMemo(() => ({ backgroundColor: theme.color.bg }), [theme.color.bg]); return {children}; }); -const LocalStrictMode = ({ children }: { children: React.ReactNode }) => { - const strict = process.env.CI !== 'true'; - return strict ? {children} : <>{children}; -}; - const App = memo(() => { - const [colorScheme, setColorScheme] = useState('light'); - const [fontsLoaded] = useFonts(); + const [colorScheme, setColorScheme] = useState('light'); - const handleOnReady = useCallback(async () => { + React.useEffect(() => { if (fontsLoaded) { - // This tells the splash screen to hide immediately! If we call this after - // `setAppIsReady`, then we may see a blank screen while the app is - // loading its initial state and rendering its first pixels. So instead, - // we hide the splash screen once we know the root view has already - // performed layout. - await SplashScreen.hideAsync(); + SplashScreen.hideAsync(); } }, [fontsLoaded]); @@ -70,20 +61,20 @@ const App = memo(() => { } return ( - + - + ); }); diff --git a/apps/expo-app/README.md b/apps/expo-app/README.md new file mode 100644 index 0000000000..530cf7e39b --- /dev/null +++ b/apps/expo-app/README.md @@ -0,0 +1,136 @@ +# expo-app + +Expo-based demo app for testing CDS mobile components. Used as the visual regression (visreg) target app for the CDS v9 branch. + +## Nx targets + +| Command | Description | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `yarn nx run expo-app:ios` | Build (if needed), install, launch, and start Metro — full dev loop for iOS (debug) | +| `yarn nx run expo-app:ios --configuration=release` | Install and launch the release build artifact (no Metro) | +| `yarn nx run expo-app:android` | Build (if needed), install, launch, and start Metro — full dev loop for Android (debug) | +| `yarn nx run expo-app:android --configuration=release` | Install and launch the release build artifact (no Metro) | +| `yarn nx run expo-app:start` | Start Metro bundler only (assumes app is already installed) | +| `yarn nx run expo-app:build --configuration=` | Compile the native app and archive to a tarball in `prebuilds/` | +| `yarn nx run expo-app:launch --configuration=` | Install + launch an existing build artifact on a simulator/emulator | +| `yarn nx run expo-app:patch-bundle-ios` | Swap the JS bundle inside the committed iOS Release prebuild — used by visreg CI | +| `yarn nx run expo-app:patch-bundle-android` | Swap the JS bundle inside the committed Android Release prebuild — used by visreg CI | +| `yarn nx run expo-app:validate` | Check Expo dependency versions for compatibility | +| `yarn nx run expo-app:lint` | Lint the app source | +| `yarn nx run expo-app:typecheck` | Type-check the app source | + +## Build configurations + +| Configuration | Platform | Profile | Target | Output | +| -------------------- | -------- | ------- | --------- | ------------------------------------------ | +| `ios-debug` | iOS | Debug | Simulator | `prebuilds/ios-debug/expoapp.tar.gz` | +| `ios-release` | iOS | Release | Simulator | `prebuilds/ios-release/expoapp.tar.gz` | +| `ios-debug-device` | iOS | Debug | Device | `prebuilds/ios-debug-device/expoapp.ipa` | +| `ios-release-device` | iOS | Release | Device | `prebuilds/ios-release-device/expoapp.ipa` | +| `android-debug` | Android | Debug | Emulator | `prebuilds/android-debug/expoapp.apk` | +| `android-release` | Android | Release | Emulator | `prebuilds/android-release/expoapp.apk` | + +## Prebuilds + +The `prebuilds/` directory contains pre-compiled native artifacts (tarballs) that are committed to the repo. This means CI and team members never need to run a full native build just to run visreg or launch the app for JS-only development. + +**Committed:** iOS tarballs (`.tar.gz`), Android release zips (`.zip`) +**Not committed:** Extracted `.app` directories (recreated at runtime from the tarball), Android debug APKs + +### Updating prebuilds + +Rebuild and commit a new tarball whenever native dependencies change (e.g. a new native module, an RN upgrade, or an Expo SDK bump): + +```bash +# iOS release (used by visreg CI) +yarn nx run expo-app:build --configuration=ios-release + +# iOS debug (used for local development) +yarn nx run expo-app:build --configuration=ios-debug + +# Then commit the updated tarballs +git add apps/expo-app/prebuilds/ +git commit -m "chore: update expo-app prebuilds" +``` + +### patch-bundle targets + +`patch-bundle-ios` and `patch-bundle-android` update the JS bundle inside an already-extracted prebuild without recompiling native code. This is what visreg CI runs instead of a full build: + +1. Extracts `prebuilds/ios-release/expoapp.tar.gz` → `prebuilds/ios-release/expoapp.app` +2. Runs `expo export` to produce a fresh JS bundle from the current branch +3. Replaces the JS bundle inside the `.app` + +The patched `.app` is then installed directly onto the simulator for screenshot capture. + +## Local development + +### iOS Simulator + +For first-time setup, see the [Expo iOS Simulator guide](https://docs.expo.dev/workflow/ios-simulator/). + +1. **Run the app**: + + ```bash + yarn nx run expo-app:ios + ``` + + This will: + - Build the app if no artifact exists at `prebuilds/ios-debug/expoapp.tar.gz` + - Boot the iOS Simulator if not already running + - Extract, install, and launch the app + - Start Metro bundler (debug only — release builds launch standalone) + +2. **Rebuild when native dependencies change**: + ```bash + rm -rf prebuilds/ios-debug + yarn nx run expo-app:ios + ``` + +### Android Emulator + +Android requires more manual steps due to expo-dev-client limitations. + +For first-time setup, see the [Expo Android Studio Emulator guide](https://docs.expo.dev/workflow/android-studio-emulator/). + +1. **Prerequisites**: + - Android Studio installed with an emulator configured + - `ANDROID_HOME` environment variable set + +2. **Run the app**: + + ```bash + yarn nx run expo-app:android + ``` + + This will: + - Build the APK if no artifact exists at `prebuilds/android-debug/expoapp.apk` + - Start the Android emulator if not already running + - Install and launch the app via adb + - Start Metro bundler + +3. **Troubleshooting**: + + If the app doesn't connect to Metro automatically: + - Press `r` in the Metro terminal to reload the app + - Or shake the device / press Cmd+M to open the dev menu and select "Reload" + + If Metro connection fails entirely: + + ```bash + adb reverse tcp:8081 tcp:8081 + ``` + + Then reload the app. + +4. **Rebuild when native dependencies change**: + ```bash + rm -rf prebuilds/android-debug + yarn nx run expo-app:android + ``` + +## Expo Go compatibility + +This app cannot run in Expo Go due to dependencies on native modules. Specifically, `@react-native-community/datetimepicker` (used by cds-mobile) contains native code not included in Expo Go. + +You must use the development build workflow described above. diff --git a/apps/expo-app/app.json b/apps/expo-app/app.json new file mode 100644 index 0000000000..8159a6f56a --- /dev/null +++ b/apps/expo-app/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "expo-app", + "slug": "expo-app", + "version": "1.0.0", + "scheme": "expoapp", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.expo-app" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "package": "com.anonymous.expoapp" + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/apps/expo-app/assets/adaptive-icon.png b/apps/expo-app/assets/adaptive-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/expo-app/assets/adaptive-icon.png differ diff --git a/apps/expo-app/assets/favicon.png b/apps/expo-app/assets/favicon.png new file mode 100644 index 0000000000..e75f697b18 Binary files /dev/null and b/apps/expo-app/assets/favicon.png differ diff --git a/apps/expo-app/assets/icon.png b/apps/expo-app/assets/icon.png new file mode 100644 index 0000000000..a0b1526fc7 Binary files /dev/null and b/apps/expo-app/assets/icon.png differ diff --git a/apps/expo-app/assets/splash-icon.png b/apps/expo-app/assets/splash-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/expo-app/assets/splash-icon.png differ diff --git a/apps/expo-app/babel.config.js b/apps/expo-app/babel.config.js new file mode 100644 index 0000000000..8c5c851083 --- /dev/null +++ b/apps/expo-app/babel.config.js @@ -0,0 +1,10 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + // IMPORTANT: react-native-worklets/plugin must be listed LAST + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/apps/mobile-app/docs/building-mobile.md b/apps/expo-app/docs/building-mobile.md similarity index 52% rename from apps/mobile-app/docs/building-mobile.md rename to apps/expo-app/docs/building-mobile.md index 11d5215062..1c58fc12f0 100644 --- a/apps/mobile-app/docs/building-mobile.md +++ b/apps/expo-app/docs/building-mobile.md @@ -25,14 +25,14 @@ It is a native module build of your application that is: ### When do you need to rebuild debug builds? - If you don't have any local debug build to develop off of -- If there's any dependency change in `apps/mobile-app/package.json` and `packages/mobile/package.json` +- If there's any dependency change in `apps/expo-app/package.json` and `packages/mobile/package.json` ### How do I rebuild a debug build? -| Platform | Profile - engine type | Command | -| -------- | --------------------- | -------------------------------------------- | -| ios | local - hermes | `yarn nx run mobile-app:build:ios-debug` | -| android | local -hermes | `yarn nx run mobile-app:build:android-debug` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | ------------------------------------------ | +| ios | local - hermes | `yarn nx run expo-app:build:ios-debug` | +| android | local -hermes | `yarn nx run expo-app:build:android-debug` | ## Release Builds @@ -46,8 +46,10 @@ It is a native module build of your application that is: ### When do you need to rebuild release builds? -- Any dependency change in `apps/mobile-app/package.json` and `packages/mobile/package.json` -- Any JS change in `packages/mobile/*`. +- Any native dependency change in `apps/expo-app/package.json` or `packages/mobile/package.json` +- Any change to native Expo config or build tooling + +> JS-only changes do not require a full rebuild — use `yarn nx run expo-app:patch-bundle-ios` / `patch-bundle-android` to swap the JS bundle into the existing prebuild instead. ### How do I rebuild a release build? @@ -56,30 +58,16 @@ Generate the new shared, native module builds for everyone to use. **Be sure to **Note: Committing these builds reduces CI time drastically by 14min for ios and 7 mins for android** ```shell -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run expo-app:build:ios-release +yarn nx run expo-app:build:android-release ``` ## Advanced ### Creating new build configurations -You can create other build types using [app.config.js](/apps/mobile-app/app.config.ts) and [project.json](/apps/mobile-app/project.json). - -Create a new config in [project.json](/apps/mobile-app/project.json) `targets.build.configurations`. The key will be come your new command for `yarn nx run mobile-app:build:`. +You can create other build types using [app.json](/apps/expo-app/app.json) and [project.json](/apps/expo-app/project.json). -Pass ENVs to configure your build. See [setEnvVars](/apps/mobile-app/scripts/utils/setEnvVars.mjs) for options and [project.json](/apps/mobile-app/project.json) for examples. +Create a new config in [project.json](/apps/expo-app/project.json) `targets.build.configurations`. The key will become your new command for `yarn nx run expo-app:build:`. [Here is the reference guide on app configurations from Expo](https://docs.expo.dev/versions/latest/config/app/). - -### Run on real device - -With [Expo Go](https://docs.expo.dev/get-started/expo-go/), you can easily run the app on a real physical device by following these steps: - -**NOTE:** For security reasons, please make sure your device has Coinbase Security Profile installed before proceeding. - -1. Download [Expo Go](https://expo.dev/client) to your device. -2. Run `yarn nx run mobile-app:go` to start the development server. This will output a QR code in your terminal. -3. Make sure your device and metro are connected to the same network. You might also need to disconnect VPN. -4. On your device, scan the QR code generated in step2. It will redirect you to Expo Go and install the debug app. -5. The app will now reload whenever you save changes in your code. diff --git a/apps/mobile-app/docs/help.md b/apps/expo-app/docs/help.md similarity index 64% rename from apps/mobile-app/docs/help.md rename to apps/expo-app/docs/help.md index d418151496..df724122f4 100644 --- a/apps/mobile-app/docs/help.md +++ b/apps/expo-app/docs/help.md @@ -3,28 +3,28 @@ ## Debugging Tools 1. How do I run `gradlew` locally for Android debugging? - Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `mobile-app`. + Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `expo-app`. To generate a local android directory to run `gradle`: -- `yarn workspace mobile-app expo prebuild --platform android` -- `cd apps/mobile-app/android` -- Run gradle command. You can always find the gradle commands executed for debug and release builds in [eas.json](/apps/mobile-app/eas.json). For debug, you can run `./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug`. For release, you can run `./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release` -- `yarn clean-expo` from root when you're done! Leaving the `android` directory will impact future builds. +- `yarn workspace expo-app expo prebuild --platform android` +- `cd apps/expo-app/android` +- Run gradle command. For debug, you can run `./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug`. For release, you can run `./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release` +- `rm -rf apps/expo-app/android` from root when you're done! Leaving the `android` directory will impact future builds. 2. How do I run `pod` locally for iOS debugging? - Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `mobile-app`. + Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `expo-app`. To generate a local ios directory to run `pod`, you can run the prebuild command from expo. This will call `pod install` for you and show you a local failure: -- `yarn workspace mobile-app expo prebuild --platform ios` -- `yarn clean-expo` from root when you're done! Leaving the `ios` directory will impact future builds. +- `yarn workspace expo-app expo prebuild --platform ios` +- `rm -rf apps/expo-app/ios apps/expo-app/.expo` from root when you're done! Leaving the `ios` directory will impact future builds. 3. Access expo build output directly for logs & to debug build failures. -Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. In order to see the build output or logs generated from the `yarn nx run mobile-app:build:`, you need to skip expo cleanup. This can also be used for `launch` +Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. In order to see the build output or logs generated from the `yarn nx run expo-app:build:`, you need to skip expo cleanup. This can also be used for `launch` -- Go to our [build script](/apps/mobile-app/scripts/build.mjs) +- Go to our [build script](/apps/expo-app/scripts/build.mjs) - Prepend `export EAS_LOCAL_BUILD_SKIP_CLEANUP=1 && ` prior to `eas build`... - Run build like normal - At the end of the build, you'll see an output `Skipping cleanup, /var/folders/.... won't be removed.` @@ -36,21 +36,21 @@ Our expo builds are 'managed', and therefore are built in a temp directory outsi ## Common Errors -1. `yarn nx run mobile-app:build:ios-debug` is throwing a `Error: spawn pod ENOENT` error. +1. `yarn nx run expo-app:build:ios-debug` is throwing a `Error: spawn pod ENOENT` error. -- Run `yarn workspace mobile-app run expo prebuild -p ios --clean` -- `yarn clean-expo` -- `yarn nx run mobile-app:build:ios-debug` should work as expected +- Run `yarn workspace expo-app run expo prebuild -p ios --clean` +- `rm -rf apps/expo-app/ios apps/expo-app/.expo` +- `yarn nx run expo-app:build:ios-debug` should work as expected -2. `yarn nx run mobile-app:build:android-debug` is throwing this error `mobile-app/android directory not found` +2. `yarn nx run expo-app:build:android-debug` is throwing this error `expo-app/android directory not found` -- Run `mkdir apps/mobile-app/android` -- `yarn nx run mobile-app:build:android-debug` should work as expected -- Delete the `mobile-app/android` directory +- Run `mkdir apps/expo-app/android` +- `yarn nx run expo-app:build:android-debug` should work as expected +- Delete the `expo-app/android` directory 3. An error like "You are on eas-cli@3.7.2 which does not satisfy the CLI version constraint in eas.json (3.8.1)" -Look up the `cli.version` in `apps/mobile-app/eas.json`. +Look up the required version in `apps/expo-app/package.json`. ```shell npm -g install eas-cli@ @@ -73,12 +73,12 @@ This error can occur for a number of reasons. See debugging section above for ho This error can occur for a number of reasons. See debugging section above for how to run pod install locally. -7. No development build (com.ui-systems.debug-ios-hermes) for this project is installed. Please make and install development build on the device first. +7. No development build (com.anonymous.expo-app) for this project is installed. Please make and install development build on the device first. -This error occurs because mobile-app/ios directory was present at the time of launching the build onto a simulator. This interferes with expos naming of the app on the device. +This error occurs because expo-app/ios directory was present at the time of launching the build onto a simulator. This interferes with expo's naming of the app on the device. To resolve from root: -`yarn clean-expo` +`rm -rf apps/expo-app/ios apps/expo-app/.expo` 8. I'm seeing this build error in my CDS log file in the expo temp directory: @@ -88,8 +88,8 @@ To resolve from root: This error can be caused by two things: -- The packages are incorrectly built. All packages should be built in the preinstall `eas-build-pre-install` script found in the `mobile-app/package.json` file. If the package has an empty or missing `lib` in the expo temp directory, it didn't correctly build. -- Metro error. Package exports are processed from the metro.config.js for local development. You can verify through `yarn.lock`. +- The packages are incorrectly built. All packages should be built in the preinstall `eas-build-pre-install` script found in the `expo-app/package.json` file. If the package has an empty or missing `lib` in the expo temp directory, it didn't correctly build. +- Metro error. Package exports are resolved by Metro during local development. You can verify the resolution through `yarn.lock`. 9. I'm seeing lots of warnings about watchman recrawling. How can I remove these from my terminal output? @@ -130,16 +130,16 @@ which watchman - Press `shift + Command + .` at the same time to see all the directories list - Drill into the directory that `which watchman` printed and find all executables prefixed with `watchman` and add each one to have Full Disk Access list - even if `watchman*` processes were previously provided full-disk-access, make sure to re-add access after reinstalling watchman -- Run `yarn nx run mobile-app:start` multiple times to confirm you are no longer seeing the warning +- Run `yarn nx run expo-app:start` multiple times to confirm you are no longer seeing the warning -10. Expo is throwing this error while I'm running a build or launch command (`yarn nx run mobile-app::`): Props Authentication Token not found, Props token or EXPO login error. +10. Expo is throwing this error while I'm running a build or launch command (`yarn nx run expo-app::`): Props Authentication Token not found, Props token or EXPO login error. Approaches to resolve: - Run the following in the root directory ```shell -cd apps/mobile-app && eas build --local --non-interactive --json --clear-cache --platform ios --profile debug +cd apps/expo-app && eas build --local --non-interactive --json --clear-cache --platform ios --profile debug ``` 11. Cocoapods is not setup or the following error is thrown: Cocoapods is not available, make sure it's installed and in your PATH. diff --git a/apps/mobile-app/docs/prebuilds.md b/apps/expo-app/docs/prebuilds.md similarity index 54% rename from apps/mobile-app/docs/prebuilds.md rename to apps/expo-app/docs/prebuilds.md index 56b283e728..435c7019c4 100644 --- a/apps/mobile-app/docs/prebuilds.md +++ b/apps/expo-app/docs/prebuilds.md @@ -1,19 +1,14 @@ ## When to use prebuilds? -Use Expo Go for normal dev workflow and testing simple JS code. Only use prebuilds if you: - -- Made dependencies changes -- Want to test native modules that require prebuilds +`expo-app` does not support Expo Go — it requires native modules (e.g. `react-native-date-picker`) that cannot run in the Expo Go sandbox. All development uses prebuilds. Use debug prebuilds for local development with hot reloading, and release prebuilds for visreg. ## Setup 1. Setup your dependencies - fastlane, and eas-cli -Get the eas version in [eas.json](/apps/mobile-app/eas.json) at `cli.version`. - ```shell brew install fastlane -npm install -g eas-cli@ +npm install -g eas-cli ``` 2. Run `yarn install` from root @@ -24,38 +19,37 @@ npm install -g eas-cli@ 1. Build the application you wish to develop on with `build` -**Note: you should only need to build if you're missing your desired build in [prebuilds](/apps/mobile-app/prebuilds) or if there's been a recent native dependency upgrade.** +**Note: you should only need to build if you're missing your desired build in [prebuilds](/apps/expo-app/prebuilds) or if there's been a recent native dependency upgrade.** -See more info about mobile builds [here](/apps/mobile-app/docs/building-mobile.md). +See more info about mobile builds [here](/apps/expo-app/docs/building-mobile.md). -| Platform | Profile - engine type | Command | -| -------- | --------------------- | ---------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:build:ios-debug` | -| ios | release - hermes | `yarn nx run mobile-app:build:ios-release` | -| android | debug - hermes | `yarn nx run mobile-app:build:android-debug` | -| android | release - hermes | `yarn nx run mobile-app:build:android-release` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | -------------------------------------------- | +| ios | debug - hermes | `yarn nx run expo-app:build:ios-debug` | +| ios | release - hermes | `yarn nx run expo-app:build:ios-release` | +| android | debug - hermes | `yarn nx run expo-app:build:android-debug` | +| android | release - hermes | `yarn nx run expo-app:build:android-release` | -**Note: If you run into errors when trying to prebuild, check out our [Help](/apps/mobile-app/docs/help.md) page to debug.** +**Note: If you run into errors when trying to prebuild, check out our [Help](/apps/expo-app/docs/help.md) page to debug.** 2. Install app in your simulator with `launch` configuration. -**Note: You can skip this if you've already launched the build in your [prebuilds](/apps/mobile-app/prebuilds) in your simulator.** +**Note: You can skip this if you've already launched the build in your [prebuilds](/apps/expo-app/prebuilds) in your simulator.** -| Platform | Profile - engine type | Command | -| -------- | --------------------- | ----------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:launch:ios-debug` | -| ios | release - hermes | `yarn nx run mobile-app:launch:ios-release` | -| android | debug - hermes | `yarn nx run mobile-app:launch:android-debug` | -| android | release - hermes | `yarn nx run mobile-app:launch:android-release` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | --------------------------------------------- | +| ios | debug - hermes | `yarn nx run expo-app:launch:ios-debug` | +| ios | release - hermes | `yarn nx run expo-app:launch:ios-release` | +| android | debug - hermes | `yarn nx run expo-app:launch:android-debug` | +| android | release - hermes | `yarn nx run expo-app:launch:android-release` | 3. Start the metro server for installed application. Only relevant for debug builds because release builds do not have hot reloading. -| Platform | Profile - engine type | Command | -| -------- | --------------------- | -------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:start:ios-debug` | -| android | debug - hermes | `yarn nx run mobile-app:start:android-debug` | +```shell +yarn nx run expo-app:start +``` -**Note: If you see `CommandError: No development build (com.ui-systems.debug-ios-hermes) for this project is installed. Please make and install a development build on the device first.` run `yarn clean-expo` and rerun the `start` script. See more debug gotchas [here](/apps/mobile-app/docs/help.md)** +**Note: If you see `CommandError: No development build (com.anonymous.expo-app) for this project is installed. Please make and install a development build on the device first.` run `rm -rf apps/expo-app/ios apps/expo-app/.expo` and rerun the `start` script. See more debug gotchas [here](/apps/expo-app/docs/help.md)** When running the debug app after a rebuild or restart, you'll most likely need to close out the Debug app and reopen it to trigger the bundler to recompile. @@ -72,21 +66,21 @@ See the [mobile-visreg README](/packages/mobile-visreg/README.md) for full setup ## An overview of Expo NX Targets -There are three core NX targets associated with Expo that we leverage to build and run mobile-app. The various contexts can be summarized as debug and release modes for development, with release builds also serving as the basis for visual regression testing. +There are three core NX targets associated with Expo that we leverage to build and run expo-app. The various contexts can be summarized as debug and release modes for development, with release builds also serving as the basis for visual regression testing. -The three NX Targets (also declared in `/apps/mobile-app/project.json`): +The three NX Targets (also declared in `/apps/expo-app/project.json`): 1. launch 2. start 3. build -These targets call node scripts that live in the [scripts directory of mobile-app](/apps/mobile-app/scripts/). These scripts are intuitively named the same as their respective nx targets. +`launch` and `build` call node scripts that live in the [scripts directory of expo-app](/apps/expo-app/scripts/). `start` runs `npx expo start` directly. Visual regression testing is handled separately by the [`packages/mobile-visreg`](/packages/mobile-visreg/README.md) package using Maestro and BrowserStack App Percy. ## Expo Debug vs Release Builds -There are four relevant build variations associated with mobile-app: +There are four relevant build variations associated with expo-app: Release builds: @@ -105,7 +99,7 @@ There are two key ideas to understand about these build variations: ## The difference between a release and a debug build -The key difference between release and debug builds is how the javascript is bundled with the native portion of mobile-app. In release builds a fully optimized version of the javascript bundle is packaged into the iOS ipa or Android apk and is referenced by the native app entry point. In a debug build the javascript bundle is not bundled into the app artifact, instead it is kept external to the shippable native portion and the native entry point references a bundle managed by the metro bundler (the metro bundler is what runs in your terminal when you run the start target). This difference is key to understanding why hot-reloading works in debug builds but not in release builds. It is also important to note here that debug is clearly a very different environment compared to release, which is why our visreg tests must be run in the context of release build as opposed to debug. +The key difference between release and debug builds is how the javascript is bundled with the native portion of expo-app. In release builds a fully optimized version of the javascript bundle is packaged into the iOS ipa or Android apk and is referenced by the native app entry point. In a debug build the javascript bundle is not bundled into the app artifact, instead it is kept external to the shippable native portion and the native entry point references a bundle managed by the metro bundler (the metro bundler is what runs in your terminal when you run the start target). This difference is key to understanding why hot-reloading works in debug builds but not in release builds. It is also important to note here that debug is clearly a very different environment compared to release, which is why our visreg tests must be run in the context of release build as opposed to debug. ## Why visreg uses release builds @@ -129,8 +123,8 @@ A key performance optimization keeps the committed prebuilds (native `.ipa` / `. Instead, CI uses a patch step: ```bash -yarn nx run mobile-app:patch-bundle-ios # iOS -yarn nx run mobile-app:patch-bundle-android # Android +yarn nx run expo-app:patch-bundle-ios # iOS +yarn nx run expo-app:patch-bundle-android # Android ``` These scripts uncompress the committed release artifact, swap in the freshly bundled JS, and re-compress it into a valid platform artifact. This makes CI visreg runs fast while keeping the native prebuilds in sync with the JS codebase. @@ -139,6 +133,6 @@ These scripts uncompress the committed release artifact, swap in the freshly bun Any time native dependencies, native Expo configs, or relevant build tooling changes. When this happens, regenerate and commit the updated prebuilds: ```bash -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run expo-app:build:ios-release +yarn nx run expo-app:build:android-release ``` diff --git a/apps/mobile-app/docs/upgrade-rn.md b/apps/expo-app/docs/upgrade-rn.md similarity index 57% rename from apps/mobile-app/docs/upgrade-rn.md rename to apps/expo-app/docs/upgrade-rn.md index 01e871fcde..bbad073a3f 100644 --- a/apps/mobile-app/docs/upgrade-rn.md +++ b/apps/expo-app/docs/upgrade-rn.md @@ -5,42 +5,42 @@ Expo handles react native upgrades through their [SDK](https://docs.expo.dev/wor 1. Update to the new SDK version from root. You can check the [latest patch version on npm](https://www.npmjs.com/package/expo): ```shell -yarn workspace mobile-app add expo@^ +yarn workspace expo-app add expo@^ ``` 2. Fixes native and expo dependencies to match recommended versions. You can override versions in package.json after running the fix command. ```shell -cd apps/mobile-app && npx expo install --fix +cd apps/expo-app && npx expo install --fix ``` 3. Upgrade all native dependencies within our repo (cds-mobile, etc) to match the versions provided by expo. -**This is super important because that native versions must match for the mobile-app build to be successful** +**This is super important because that native versions must match for the expo-app build to be successful** -4. Nuke your repo. Cached versions will be compiled in the expo build step and lead to version mismatches. .nx, apps/mobile-app/expo, apps/mobile-app/ios, apps/mobile-app/android should all be removed. Node Modules should be removed because of version mismatches as well. Start fresh :) +4. Nuke your repo. Cached versions will be compiled in the expo build step and lead to version mismatches. `.nx`, `apps/expo-app/.expo`, `apps/expo-app/ios`, `apps/expo-app/android` should all be removed. Node modules should be removed because of version mismatches as well. Start fresh :) ```shell -cd ../../ && yarn clean-expo && yarn clean && rm -rf node_modules +cd ../../ && rm -rf apps/expo-app/ios apps/expo-app/android apps/expo-app/.expo && yarn clean && rm -rf node_modules ``` -6. Reboot with `yarn` +5. Reboot with `yarn` -7. Resolve any errors generated from dependency bumps or the react native upgrade. +6. Resolve any errors generated from dependency bumps or the react native upgrade. ```shell yarn nx run mobile:build ``` -8. Test debug builds and generate the new shared, native module builds for everyone to use. Be sure to commit ios debug, ios release, and android release builds to your PR. +7. Test debug builds and generate the new shared, native module builds for everyone to use. Be sure to commit ios debug, ios release, and android release builds to your PR. ```shell -yarn nx run mobile-app:build:ios-debug -yarn nx run mobile-app:build:android-debug -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run expo-app:build:ios-debug +yarn nx run expo-app:build:android-debug +yarn nx run expo-app:build:ios-release +yarn nx run expo-app:build:android-release ``` ## Having trouble? -[See our help docs](/apps/mobile-app/docs/help.md) +[See our help docs](/apps/expo-app/docs/help.md) diff --git a/apps/mobile-app/docs/upgrading-mobile-dep.md b/apps/expo-app/docs/upgrading-mobile-dep.md similarity index 82% rename from apps/mobile-app/docs/upgrading-mobile-dep.md rename to apps/expo-app/docs/upgrading-mobile-dep.md index 8143c17799..c3167c2ec1 100644 --- a/apps/mobile-app/docs/upgrading-mobile-dep.md +++ b/apps/expo-app/docs/upgrading-mobile-dep.md @@ -2,14 +2,14 @@ Expo handles react native upgrades through their [SDK](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/). Their SDK will handle updating native modules, as well as recommend native package versions that are compatible with the new react native version. -Check out this doc [for more about mobile builds in general](/apps/mobile-app/docs/building-mobile.md) +Check out this doc [for more about mobile builds in general](/apps/expo-app/docs/building-mobile.md) **We can stray from their recommendations, but with caution.** 1. Update to the new package in all relevant packages. ```shell -yarn workspace mobile-app add @ +yarn workspace expo-app add @ yarn workspace @coinbase/cds-mobile add @ yarn ``` @@ -20,12 +20,12 @@ yarn yarn nx run mobile:test ``` -3. Test that your applications work locally as expected. You will need to build a new debug build & likely uninstall the previous application and reinstall your new build, following [setup instructions](/apps/mobile-app/README.md). +3. Test that your applications work locally as expected. You will need to build a new debug build & likely uninstall the previous application and reinstall your new build, following [setup instructions](/apps/expo-app/README.md). 4. Generate the new shared, native module builds for everyone to use. Be sure to commit the release builds. Visreg (via `packages/mobile-visreg`) uses the release builds to capture and compare screenshots. The android-debug build is too large to be committed locally, but should be tested. ```shell -yarn nx run mobile-app:build:ios-debug -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run expo-app:build:ios-debug +yarn nx run expo-app:build:ios-release +yarn nx run expo-app:build:android-release ``` diff --git a/apps/expo-app/index.js b/apps/expo-app/index.js new file mode 100644 index 0000000000..131fa8562d --- /dev/null +++ b/apps/expo-app/index.js @@ -0,0 +1,10 @@ +import './polyfills/intl'; + +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/expo-app/package.json b/apps/expo-app/package.json new file mode 100644 index 0000000000..d883b5b8f0 --- /dev/null +++ b/apps/expo-app/package.json @@ -0,0 +1,42 @@ +{ + "name": "expo-app", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@coinbase/cds-icons": "workspace:^", + "@coinbase/cds-mobile": "workspace:^", + "@expo-google-fonts/inter": "^0.3.0", + "@expo-google-fonts/source-code-pro": "^0.3.0", + "@formatjs/intl-getcanonicallocales": "^2.5.5", + "@formatjs/intl-locale": "^4.2.11", + "@formatjs/intl-numberformat": "^8.15.4", + "@formatjs/intl-pluralrules": "^5.4.4", + "@react-navigation/native": "6.1.17", + "@react-navigation/native-stack": "6.9.26", + "@shopify/react-native-skia": "2.2.12", + "expo": "54.0.32", + "expo-dev-client": "6.0.20", + "expo-font": "14.0.11", + "expo-linking": "~8.0.11", + "expo-splash-screen": "31.0.13", + "expo-status-bar": "3.0.9", + "intl": "^1.2.5", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-date-picker": "5.0.12", + "react-native-gesture-handler": "2.28.0", + "react-native-inappbrowser-reborn": "3.7.0", + "react-native-navigation-bar-color": "2.0.2", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-screens": "4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" + }, + "private": true, + "scripts": { + "android": "expo run:android", + "ios": "expo run:ios" + } +} diff --git a/apps/mobile-app/src/polyfills/intl.ts b/apps/expo-app/polyfills/intl.ts similarity index 100% rename from apps/mobile-app/src/polyfills/intl.ts rename to apps/expo-app/polyfills/intl.ts diff --git a/apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz b/apps/expo-app/prebuilds/ios-release/expoapp.tar.gz similarity index 71% rename from apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz rename to apps/expo-app/prebuilds/ios-release/expoapp.tar.gz index 5ca70c1bd8..928c1c9d46 100644 Binary files a/apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz and b/apps/expo-app/prebuilds/ios-release/expoapp.tar.gz differ diff --git a/apps/expo-app/project.json b/apps/expo-app/project.json new file mode 100644 index 0000000000..a51643e50f --- /dev/null +++ b/apps/expo-app/project.json @@ -0,0 +1,115 @@ +{ + "name": "expo-app", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/expo-app", + "tags": [], + "targets": { + "start": { + "command": "npx expo start", + "options": { + "cwd": "apps/expo-app" + } + }, + "ios": { + "executor": "nx:run-commands", + "defaultConfiguration": "debug", + "configurations": { + "debug": { + "cwd": "apps/expo-app", + "command": "node ./scripts/run.mjs --platform ios --profile debug" + }, + "release": { + "cwd": "apps/expo-app", + "command": "node ./scripts/run.mjs --platform ios --profile release" + } + } + }, + "android": { + "executor": "nx:run-commands", + "defaultConfiguration": "debug", + "configurations": { + "debug": { + "cwd": "apps/expo-app", + "command": "node ./scripts/run.mjs --platform android --profile debug" + }, + "release": { + "cwd": "apps/expo-app", + "command": "node ./scripts/run.mjs --platform android --profile release" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "typecheck": { + "command": "tsc --build --pretty --verbose" + }, + "validate": { + "command": "npx expo install --check", + "options": { + "cwd": "apps/expo-app" + } + }, + "launch": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/expo-app", + "command": "node ./scripts/launch.mjs" + }, + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug" + }, + "ios-release": { + "args": "--platform ios --profile release" + }, + "android-debug": { + "args": "--platform android --profile debug" + }, + "android-release": { + "args": "--platform android --profile release" + } + } + }, + "patch-bundle-ios": { + "command": "node ./scripts/patch-bundle.mjs --platform ios --profile release", + "options": { + "cwd": "apps/expo-app" + } + }, + "patch-bundle-android": { + "command": "node ./scripts/patch-bundle.mjs --platform android --profile release", + "options": { + "cwd": "apps/expo-app" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/expo-app", + "command": "node ./scripts/build.mjs" + }, + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug --target simulator" + }, + "ios-release": { + "args": "--platform ios --profile release --target simulator" + }, + "ios-debug-device": { + "args": "--platform ios --profile debug --target device" + }, + "ios-release-device": { + "args": "--platform ios --profile release --target device" + }, + "android-debug": { + "args": "--platform android --profile debug --target simulator" + }, + "android-release": { + "args": "--platform android --profile release --target simulator" + } + } + } + } +} diff --git a/apps/expo-app/scripts/build.mjs b/apps/expo-app/scripts/build.mjs new file mode 100644 index 0000000000..f01219aeae --- /dev/null +++ b/apps/expo-app/scripts/build.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + target: { type: 'string', default: 'simulator' }, + }, +}); + +const { platform, profile, target } = values; + +if (!platform) { + console.error( + 'Usage: node build.mjs --platform [--profile ] [--target ]', + ); + process.exit(1); +} + +if (target !== 'simulator' && target !== 'device') { + console.error('Error: --target must be "simulator" or "device"'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target }); +const builder = createBuilder(buildInfo); + +await builder.build(); + +console.log(`\nBuild artifacts are in: ${buildInfo.outputPath}/`); +process.exit(0); diff --git a/apps/expo-app/scripts/launch.mjs b/apps/expo-app/scripts/launch.mjs new file mode 100644 index 0000000000..e213a6ba3d --- /dev/null +++ b/apps/expo-app/scripts/launch.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node launch.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +// Check that build artifact exists +if (!(await builder.hasBuildArtifact())) { + const config = `${platform}-${profile}`; + console.error(`Error: Build artifact not found.`); + console.error(`Run: yarn nx run expo-app:build --configuration=${config}`); + process.exit(1); +} + +// Install and launch +await builder.install(); +await builder.launch(); + +console.log('\nApp launched! Run "yarn nx run expo-app:start" to connect Metro.'); diff --git a/apps/expo-app/scripts/patch-bundle.mjs b/apps/expo-app/scripts/patch-bundle.mjs new file mode 100644 index 0000000000..4fddaf0fef --- /dev/null +++ b/apps/expo-app/scripts/patch-bundle.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Patches a fresh JS bundle into a pre-built native artifact (iOS .app / Android APK). + * This avoids a full native rebuild in CI — only the JS layer is updated. + * + * Usage: + * node scripts/patch-bundle.mjs --platform ios [--profile release] + * node scripts/patch-bundle.mjs --platform android [--profile release] + * + * Prerequisites: a build artifact must already exist at builds/{platform}-{profile}/. + * Build one with: yarn nx run expo-app:build --configuration={platform}-{profile} + */ +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'release' }, + }, +}); + +const { platform, profile } = values; + +if (!platform || !['ios', 'android'].includes(platform)) { + console.error( + 'Usage: node patch-bundle.mjs --platform [--profile ]', + ); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +await builder.patchBundle(); diff --git a/apps/expo-app/scripts/run.mjs b/apps/expo-app/scripts/run.mjs new file mode 100644 index 0000000000..34a3a4f4fc --- /dev/null +++ b/apps/expo-app/scripts/run.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Smart run script that uses pre-built artifacts if available, + * otherwise falls back to building from source. + */ +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node run.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +await builder.buildIfNeeded(); + +console.log(`Launching ${platform}...`); +await builder.ensureSimulatorRunning(); +await builder.install(); +await builder.launch(); + +if (profile === 'debug') { + await builder.startMetro(); +} diff --git a/apps/expo-app/scripts/utils/AndroidBuilder.mjs b/apps/expo-app/scripts/utils/AndroidBuilder.mjs new file mode 100644 index 0000000000..cac31d5706 --- /dev/null +++ b/apps/expo-app/scripts/utils/AndroidBuilder.mjs @@ -0,0 +1,155 @@ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class AndroidBuilder extends PlatformBuilder { + get android() { + return this.buildInfo.android; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.android.apk); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const isDebug = this.buildInfo.profile === 'debug'; + const buildType = isDebug ? 'Debug' : 'Release'; + const buildTypeLC = buildType.toLowerCase(); + + console.log(`Building Android app (${buildType})...`); + + await fs.mkdir(outputPath, { recursive: true }); + + const gradleTask = isDebug ? 'assembleDebug' : 'assembleRelease'; + await run('./gradlew', [`:app:${gradleTask}`, '--no-daemon'], { + cwd: this.android.projectPath, + }); + + // Copy the built APK to output directory + const builtApkDir = path.join( + this.android.projectPath, + 'app', + 'build', + 'outputs', + 'apk', + buildTypeLC, + ); + const builtApkPath = path.join(builtApkDir, `app-${buildTypeLC}.apk`); + + try { + await fs.access(builtApkPath); + await fs.copyFile(builtApkPath, this.android.apk); + console.log(`Android APK created: ${this.android.apk}`); + } catch { + throw new Error(`APK not found at ${builtApkPath}`); + } + } + + // ───────────────────────────────────────────────────────────────── + // Emulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('adb', ['devices']); + const lines = output.split('\n').slice(1); // Skip header + return lines.some((line) => line.trim() && line.includes('\tdevice')); + } + + async bootSimulator() { + console.log('No Android emulator running, starting one...'); + + const avdList = await runCapture('emulator', ['-list-avds']); + const avds = avdList.trim().split('\n').filter(Boolean); + + if (avds.length === 0) { + throw new Error('No Android Virtual Devices found. Create one in Android Studio first.'); + } + + const avd = avds[0]; + console.log(`Starting emulator: ${avd}`); + + // Start emulator in background (detached) + spawn('emulator', ['-avd', avd], { + detached: true, + stdio: 'ignore', + }).unref(); + + console.log('Waiting for emulator to boot...'); + await run('adb', ['wait-for-device']); + } + + async waitForSimulator() { + const maxAttempts = 60; + for (let i = 0; i < maxAttempts; i++) { + try { + const result = await runCapture('adb', ['shell', 'getprop', 'sys.boot_completed']); + if (result.trim() === '1') return; + } catch { + // Device not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error('Emulator failed to boot within timeout'); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + // Android APKs don't need extraction + } + + async install() { + console.log('Installing on Android Emulator...'); + await run('adb', ['install', '-r', this.android.apk]); + } + + async launch() { + console.log(`Launching ${this.android.packageId}...`); + await run('adb', ['shell', 'am', 'start', '-n', `${this.android.packageId}/.MainActivity`]); + } + + async applyBundle(bundlePath) { + const apk = path.resolve(this.android.apk); + const patchDir = `/tmp/apk-patch-${Date.now()}`; + const assetsDir = `${patchDir}/assets`; + const patchedBundle = `${assetsDir}/index.android.bundle`; + const alignedApk = `${apk}.aligned`; + + await fs.mkdir(assetsDir, { recursive: true }); + await fs.copyFile(bundlePath, patchedBundle); + + // Replace assets/index.android.bundle in the APK (cd into patchDir so zip path is correct) + console.log(`\nPatching bundle into APK: ${apk}...`); + execSync(`zip -u ${apk} assets/index.android.bundle`, { cwd: patchDir, stdio: 'inherit' }); + + // Re-align (zip modification breaks alignment) then re-sign with debug keystore + execSync(`zipalign -f 4 ${apk} ${alignedApk}`, { stdio: 'inherit' }); + await fs.rename(alignedApk, apk); + + const debugKeystore = path.resolve(process.env.HOME, '.android/debug.keystore'); + execSync( + `apksigner sign --ks ${debugKeystore} --ks-pass pass:android --key-pass pass:android ${apk}`, + { stdio: 'inherit' }, + ); + + await fs.rm(patchDir, { recursive: true }); + + console.log('Android bundle patched successfully.'); + console.log(`APK ready at: ${apk}`); + } +} diff --git a/apps/expo-app/scripts/utils/IOSBuilder.mjs b/apps/expo-app/scripts/utils/IOSBuilder.mjs new file mode 100644 index 0000000000..5268cf584a --- /dev/null +++ b/apps/expo-app/scripts/utils/IOSBuilder.mjs @@ -0,0 +1,196 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class IOSBuilder extends PlatformBuilder { + get ios() { + return this.buildInfo.ios; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.ios.appTarball); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const configuration = this.buildInfo.profile === 'debug' ? 'Debug' : 'Release'; + + if (this.ios.isDevice) { + await this.#compileForDevice(configuration); + } else { + await this.#compileForSimulator(configuration, outputPath); + } + } + + async #compileForSimulator(configuration, outputPath) { + const buildDir = path.resolve('build'); + + console.log(`Building iOS app (${configuration}) for simulator...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-derivedDataPath', + buildDir, + 'build', + ]); + + // Find the built .app and create tarball + const configFolder = `${configuration}-iphonesimulator`; + const appPath = path.join( + buildDir, + 'Build', + 'Products', + configFolder, + `${this.ios.scheme}.app`, + ); + const appDir = path.dirname(appPath); + const appName = path.basename(appPath); + + console.log(`Creating tarball: ${this.ios.appTarball}`); + await run('tar', ['-czf', path.resolve(this.ios.appTarball), '-C', appDir, appName]); + + // Clean up + await fs.rm(buildDir, { recursive: true, force: true }); + console.log(`iOS simulator build created: ${this.ios.appTarball}`); + } + + async #compileForDevice(configuration) { + const { outputPath } = this.buildInfo; + + console.log(`Archiving iOS app (${configuration}) for device...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-archivePath', + this.ios.archivePath, + 'archive', + 'CODE_SIGN_IDENTITY=-', + 'AD_HOC_CODE_SIGNING_ALLOWED=YES', + ]); + + console.log('Exporting IPA...'); + await run('xcodebuild', [ + '-exportArchive', + '-archivePath', + this.ios.archivePath, + '-exportPath', + outputPath, + '-exportOptionsPlist', + this.ios.exportOptionsPlist, + '-allowProvisioningUpdates', + ]); + + // Rename if needed + const exportedIpa = path.join(outputPath, `${this.ios.scheme}.ipa`); + try { + await fs.access(exportedIpa); + await fs.rename(exportedIpa, this.ios.ipa); + } catch { + // Already named correctly + } + + // Clean up archive + await fs.rm(this.ios.archivePath, { recursive: true, force: true }); + console.log(`iOS device build created: ${this.ios.ipa}`); + } + + // ───────────────────────────────────────────────────────────────── + // Simulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']); + const json = JSON.parse(output); + const bootedDevices = Object.values(json.devices).flat(); + return bootedDevices.length > 0; + } + + async bootSimulator() { + console.log('No iOS Simulator running, booting one...'); + + // Find an available iPhone + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'available', '-j']); + const json = JSON.parse(output); + + let deviceUDID = null; + let deviceName = null; + + for (const [runtime, devices] of Object.entries(json.devices)) { + if (runtime.includes('iOS')) { + const iphone = devices.find((d) => d.name.includes('iPhone') && d.isAvailable); + if (iphone) { + deviceUDID = iphone.udid; + deviceName = iphone.name; + break; + } + } + } + + if (!deviceUDID) { + throw new Error('No available iPhone simulator found.'); + } + + console.log(`Booting ${deviceName}...`); + await run('xcrun', ['simctl', 'boot', deviceUDID]); + await run('open', ['-a', 'Simulator']); + } + + async waitForSimulator() { + await run('xcrun', ['simctl', 'bootstatus', 'booted', '-b']); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + try { + await fs.access(this.ios.app); + } catch { + console.log(`Extracting ${this.ios.appTarball}...`); + await run('tar', ['-xzf', this.ios.appTarball, '-C', this.buildInfo.outputPath]); + } + } + + async install() { + await this.extractArtifact(); + console.log('Installing on iOS Simulator...'); + await run('xcrun', ['simctl', 'install', 'booted', this.ios.app]); + } + + async launch() { + console.log(`Launching ${this.ios.bundleId}...`); + await run('xcrun', ['simctl', 'launch', 'booted', this.ios.bundleId]); + } + + async applyBundle(bundlePath) { + const outBundle = `${this.ios.app}/main.jsbundle`; + console.log(`\nCopying bundle → ${outBundle}...`); + await fs.copyFile(bundlePath, outBundle); + console.log('iOS bundle patched successfully.'); + console.log(`App ready at: ${this.ios.app}`); + } +} diff --git a/apps/expo-app/scripts/utils/PlatformBuilder.mjs b/apps/expo-app/scripts/utils/PlatformBuilder.mjs new file mode 100644 index 0000000000..cdd2e180b7 --- /dev/null +++ b/apps/expo-app/scripts/utils/PlatformBuilder.mjs @@ -0,0 +1,134 @@ +import { execSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; + +import { run } from './shell.mjs'; + +/** + * Abstract base class for platform-specific build operations. + * iOS and Android implement the abstract methods differently. + */ +export class PlatformBuilder { + constructor(buildInfo) { + this.buildInfo = buildInfo; + } + + // ───────────────────────────────────────────────────────────────── + // Abstract methods - must be implemented by subclasses + // ───────────────────────────────────────────────────────────────── + + /** Check if the build artifact exists */ + async hasBuildArtifact() { + throw new Error('Not implemented'); + } + + /** Compile the native app (xcodebuild / gradle) */ + async compile() { + throw new Error('Not implemented'); + } + + /** Check if a simulator/emulator is currently running */ + async isSimulatorRunning() { + throw new Error('Not implemented'); + } + + /** Boot a simulator/emulator */ + async bootSimulator() { + throw new Error('Not implemented'); + } + + /** Wait for the simulator/emulator to be fully ready */ + async waitForSimulator() { + throw new Error('Not implemented'); + } + + /** Extract build artifact if needed (e.g., untar .tar.gz) */ + async extractArtifact() { + throw new Error('Not implemented'); + } + + /** Install the app on the simulator/emulator */ + async install() { + throw new Error('Not implemented'); + } + + /** Launch the app */ + async launch() { + throw new Error('Not implemented'); + } + + /** Apply a bundle file to the native artifact — platform-specific */ + async applyBundle(_bundlePath) { + throw new Error('Not implemented'); + } + + // ───────────────────────────────────────────────────────────────── + // Shared methods - common to both platforms + // ───────────────────────────────────────────────────────────────── + + /** Run expo prebuild to generate native project files */ + async prebuild() { + const { platform } = this.buildInfo; + console.log(`Running prebuild for ${platform}...`); + await run('npx', ['expo', 'prebuild', '--platform', platform, '--clean']); + } + + /** Full build: prebuild + compile */ + async build() { + const { platform, profile, outputPath } = this.buildInfo; + console.log(`Building ${platform} (${profile})...`); + + await fs.mkdir(outputPath, { recursive: true }); + await this.prebuild(); + await this.compile(); + } + + /** Build only if artifact doesn't exist */ + async buildIfNeeded() { + if (!(await this.hasBuildArtifact())) { + console.log('No build artifact found, building...'); + await this.build(); + } + } + + /** Ensure simulator is running, boot if needed */ + async ensureSimulatorRunning() { + if (!(await this.isSimulatorRunning())) { + await this.bootSimulator(); + } + await this.waitForSimulator(); + } + + /** + * Patches a fresh JS bundle into the pre-built native artifact. + * Runs expo export, finds the output bundle, then delegates platform-specific + * placement to applyBundle(). + */ + async patchBundle() { + const { platform } = this.buildInfo; + const exportDir = `/tmp/expo-export-${platform}`; + + await this.extractArtifact(); + + console.log(`\nExporting JS bundle to ${exportDir}...`); + execSync(`npx expo export --platform ${platform} --output-dir ${exportDir}`, { + stdio: 'inherit', + }); + + const jsDir = `${exportDir}/_expo/static/js/${platform}`; + const bundleFiles = readdirSync(jsDir).filter((f) => f.startsWith('index-')); + if (bundleFiles.length === 0) { + throw new Error(`No bundle found in ${jsDir}`); + } + const bundlePath = `${jsDir}/${bundleFiles[0]}`; + console.log(`\nFound bundle: ${bundlePath}`); + + await this.applyBundle(bundlePath); + } + + /** Start Metro bundler */ + async startMetro() { + console.log('\nStarting Metro bundler...'); + await run('npx', ['expo', 'start'], { interactive: true }); + } +} diff --git a/apps/expo-app/scripts/utils/createBuilder.mjs b/apps/expo-app/scripts/utils/createBuilder.mjs new file mode 100644 index 0000000000..e090a8f9f9 --- /dev/null +++ b/apps/expo-app/scripts/utils/createBuilder.mjs @@ -0,0 +1,12 @@ +import { AndroidBuilder } from './AndroidBuilder.mjs'; +import { IOSBuilder } from './IOSBuilder.mjs'; + +/** + * Factory function to create the appropriate platform builder. + */ +export function createBuilder(buildInfo) { + if (buildInfo.platform === 'ios') { + return new IOSBuilder(buildInfo); + } + return new AndroidBuilder(buildInfo); +} diff --git a/apps/expo-app/scripts/utils/exportOptions.plist b/apps/expo-app/scripts/utils/exportOptions.plist new file mode 100644 index 0000000000..d5b9e3ee1d --- /dev/null +++ b/apps/expo-app/scripts/utils/exportOptions.plist @@ -0,0 +1,14 @@ + + + + + method + development + compileBitcode + + thinning + <none> + signingStyle + automatic + + diff --git a/apps/expo-app/scripts/utils/getBuildInfo.mjs b/apps/expo-app/scripts/utils/getBuildInfo.mjs new file mode 100644 index 0000000000..055e373ed0 --- /dev/null +++ b/apps/expo-app/scripts/utils/getBuildInfo.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; + +const OUTPUT_DIRECTORY = 'prebuilds'; +const APP_NAME = 'expoapp'; +const IOS_SCHEME = 'expoapp'; +const IOS_BUNDLE_ID = 'com.anonymous.expo-app'; +const ANDROID_PACKAGE_ID = 'com.anonymous.expoapp'; + +export function getBuildInfo({ platform, profile, target = 'simulator' }) { + const isDevice = target === 'device'; + // Default builds are for simulator/emulator, device builds get -device suffix + const buildId = isDevice ? `${platform}-${profile}-device` : `${platform}-${profile}`; + const outputPath = `${OUTPUT_DIRECTORY}/${buildId}`; + + const ios = { + scheme: IOS_SCHEME, + bundleId: IOS_BUNDLE_ID, + workspace: path.resolve('ios', 'expoapp.xcworkspace'), + isDevice, + destination: isDevice ? 'generic/platform=iOS' : 'generic/platform=iOS Simulator', + archivePath: `${outputPath}/${APP_NAME}.xcarchive`, + app: `${outputPath}/${APP_NAME}.app`, + appTarball: `${outputPath}/${APP_NAME}.tar.gz`, + ipa: `${outputPath}/${APP_NAME}.ipa`, + exportOptionsPlist: path.resolve('scripts/utils/exportOptions.plist'), + }; + + const android = { + packageId: ANDROID_PACKAGE_ID, + projectPath: path.resolve('android'), + apk: `${outputPath}/${APP_NAME}.apk`, + testApk: `${outputPath}/${APP_NAME}-androidTest.apk`, + }; + + return { + platform, + profile, + target, + buildId, + outputDirectory: OUTPUT_DIRECTORY, + outputPath, + ios, + android, + }; +} diff --git a/apps/expo-app/scripts/utils/shell.mjs b/apps/expo-app/scripts/utils/shell.mjs new file mode 100644 index 0000000000..ef577fce6f --- /dev/null +++ b/apps/expo-app/scripts/utils/shell.mjs @@ -0,0 +1,44 @@ +import { spawn } from 'node:child_process'; + +/** + * Runs a command with inherited stdio (output goes to terminal). + */ +export function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + if (!options.silent) { + console.log(`> ${command} ${args.join(' ')}`); + } + const child = spawn(command, args, { + stdio: 'inherit', + shell: false, + ...options, + }); + child.on('close', (code) => { + if (code === 0 || options.ignoreError) resolve(); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', (err) => { + if (options.ignoreError) resolve(); + else reject(err); + }); + }); +} + +/** + * Runs a command and captures its stdout (instead of inheriting stdio). + * Used when we need to parse the output of a command. + */ +export function runCapture(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ''; + child.stdout.on('data', (data) => { + stdout += data; + }); + child.on('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', reject); + }); +} diff --git a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts b/apps/expo-app/src/__generated__/iconSvgMap.ts similarity index 99% rename from packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts rename to apps/expo-app/src/__generated__/iconSvgMap.ts index 22845037d5..4ce64ef6a9 100644 --- a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts +++ b/apps/expo-app/src/__generated__/iconSvgMap.ts @@ -1,7 +1,7 @@ /** * DO NOT MODIFY - * This file is generated by ui-mobile-playground/scripts/generateIconSvgMap.ts - * + * This file is generated by libs/codegen/src/icons/generateIconSvgMap.ts + * * Why this exists: * - Provides a static map of icon names to their SVG content for rendering Icons directly with react-native-svg components * diff --git a/apps/mobile-app/src/hooks/useFonts.ts b/apps/expo-app/src/hooks/useFonts.ts similarity index 96% rename from apps/mobile-app/src/hooks/useFonts.ts rename to apps/expo-app/src/hooks/useFonts.ts index 133dc772ab..dbd71e608a 100644 --- a/apps/mobile-app/src/hooks/useFonts.ts +++ b/apps/expo-app/src/hooks/useFonts.ts @@ -1,4 +1,3 @@ -// import { useEffect, useState } from 'react'; import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular'; import { Inter_600SemiBold } from '@expo-google-fonts/inter/600SemiBold'; import { useFonts as useFontsInter } from '@expo-google-fonts/inter/useFonts'; diff --git a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx b/apps/expo-app/src/playground/ExamplesListScreen.tsx similarity index 74% rename from packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx rename to apps/expo-app/src/playground/ExamplesListScreen.tsx index a780a1499f..b17ecef828 100644 --- a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx +++ b/apps/expo-app/src/playground/ExamplesListScreen.tsx @@ -1,29 +1,30 @@ import React, { useCallback, useContext } from 'react'; import { FlatList } from 'react-native'; import type { ListRenderItem } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { CellSpacing } from '@coinbase/cds-mobile/cells/Cell'; import { ListCell } from '@coinbase/cds-mobile/cells/ListCell'; import { Box } from '@coinbase/cds-mobile/layout/Box'; -import { useNavigation, useRoute } from '@react-navigation/native'; -import includes from 'lodash/includes'; +import { useNavigation } from '@react-navigation/native'; import { SearchFilterContext } from './ExamplesSearchProvider'; import { keyToRouteName } from './keyToRouteName'; -import { initialRouteKey, searchRouteKey } from './staticRoutes'; +import type { ExamplesListScreenProps } from './types'; + +const initialRouteKey = 'Examples'; +const searchRouteKey = 'Search'; const innerSpacingConfig: CellSpacing = { paddingX: 1 }; -export function ExamplesListScreen() { +export function ExamplesListScreen({ route }: ExamplesListScreenProps) { const searchFilter = useContext(SearchFilterContext); - - // React Navigation Route Param typing is not clean because our routes are dynamic - const routeKeys = (useRoute().params as { routeKeys: string[] } | undefined)?.routeKeys ?? []; + const routeKeys = route.params?.routeKeys ?? []; const { navigate } = useNavigation(); + const { bottom } = useSafeAreaInsets(); const renderItem: ListRenderItem = useCallback( ({ item }) => { const handlePress = () => { - // typing not clean due to dynamic routes navigate(keyToRouteName(item) as never); }; @@ -46,7 +47,7 @@ export function ExamplesListScreen() { .filter((key) => key !== initialRouteKey && key !== searchRouteKey) .filter((key) => { if (searchFilter !== '') { - return includes(key.toLowerCase(), searchFilter.toLowerCase()); + return key.toLowerCase().includes(searchFilter.toLowerCase()); } return true; }); @@ -55,6 +56,7 @@ export function ExamplesListScreen() { { if (iconSize <= 12) return 12; if (iconSize <= 16) return 16; diff --git a/apps/expo-app/src/playground/Playground.tsx b/apps/expo-app/src/playground/Playground.tsx new file mode 100644 index 0000000000..78badfc0ed --- /dev/null +++ b/apps/expo-app/src/playground/Playground.tsx @@ -0,0 +1,260 @@ +import React, { memo, useContext, useMemo } from 'react'; +import type { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { ColorScheme } from '@coinbase/cds-common/core/theme'; +import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; +import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; +import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Box } from '@coinbase/cds-mobile/layout/Box'; +import { HStack } from '@coinbase/cds-mobile/layout/HStack'; +import { Spacer } from '@coinbase/cds-mobile/layout/Spacer'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import { ExamplesListScreen } from './ExamplesListScreen'; +import { + ExamplesSearchProvider, + SearchFilterContext, + SetSearchFilterContext, +} from './ExamplesSearchProvider'; +import { IconSheetScreen } from './IconSheetScreen'; +import { keyToRouteName } from './keyToRouteName'; +import type { PlaygroundRoute } from './PlaygroundRoute'; +import type { PlaygroundStackParamList } from './types'; + +const initialRouteName = keyToRouteName('Examples'); +const searchRouteName = keyToRouteName('Search'); + +const Stack = createNativeStackNavigator(); + +const titleOverrides: Record = { + Examples: 'CDS', + Text: 'Text (all)', +}; + +type PlaygroundProps = { + routes?: PlaygroundRoute[]; + listScreenTitle?: string; + setColorScheme?: React.Dispatch>; +}; + +type HeaderProps = { + isSearch: boolean; + showBackButton: boolean; + showSearch: boolean; + title: string; + onGoBack: () => void; + onGoBackFromSearch: () => void; + onGoToSearch: () => void; + onToggleTheme: () => void; + onSearchChange: (e: NativeSyntheticEvent) => void; + searchFilter: string; + isDark: boolean; +}; + +const HeaderContent = memo( + ({ + isSearch, + showBackButton, + showSearch, + title, + onGoBack, + onGoBackFromSearch, + onGoToSearch, + onToggleTheme, + onSearchChange, + searchFilter, + isDark, + }: HeaderProps) => { + const { top } = useSafeAreaInsets(); + const style = useMemo(() => ({ paddingTop: top }), [top]); + + const iconButtonPlaceholder = ( + + + + ); + + const leftHeaderButton = showSearch ? ( + + + + ) : showBackButton ? ( + + + + ) : ( + iconButtonPlaceholder + ); + + const rightHeaderButton = isSearch ? ( + iconButtonPlaceholder + ) : ( + + + + ); + + return ( + + + {leftHeaderButton} + + + {isSearch ? ( + + } + value={searchFilter} + /> + ) : ( + + {title} + + )} + + + {rightHeaderButton} + + + ); + }, +); + +const PlaygroundContent = memo( + ({ routes = [], listScreenTitle, setColorScheme }: PlaygroundProps) => { + const theme = useTheme(); + const searchFilter = useContext(SearchFilterContext); + const setFilter = useContext(SetSearchFilterContext); + + const routeKeys = useMemo(() => routes.map(({ key }) => key), [routes]); + + const screenOptions = useMemo( + (): NativeStackNavigationOptions => ({ + headerBackTitleVisible: false, + headerStyle: { + backgroundColor: theme.color.bg, + }, + headerShadowVisible: false, + header: ({ navigation, route, options }) => { + const routeName = route.name; + const isSearch = routeName === searchRouteName; + const showSearch = routeName === initialRouteName; + const canGoBack = navigation.canGoBack(); + const isFocused = navigation.isFocused(); + const showBackButton = isFocused && canGoBack && !isSearch; + + const handleGoBack = () => navigation.goBack(); + const handleGoBackFromSearch = () => { + setFilter(''); + navigation.goBack(); + }; + const handleGoToSearch = () => navigation.navigate(searchRouteName); + const handleToggleTheme = () => + setColorScheme?.((s) => (s === 'dark' ? 'light' : 'dark')); + const handleSearchChange = (e: NativeSyntheticEvent) => + setFilter(e.nativeEvent.text); + + return ( + + ); + }, + }), + [theme.color.bg, theme.activeColorScheme, searchFilter, setFilter, setColorScheme], + ); + + const exampleScreens = useMemo( + () => + [...routes].map((route) => { + const { key, getComponent } = route; + const name = keyToRouteName(key); + const title = titleOverrides[key] ?? key; + return ( + } + name={name} + options={{ title }} + /> + ); + }), + [routes], + ); + + return ( + + + + + {exampleScreens} + + ); + }, +); + +export const Playground = memo((props: PlaygroundProps) => { + return ( + + + + ); +}); diff --git a/apps/expo-app/src/playground/PlaygroundRoute.ts b/apps/expo-app/src/playground/PlaygroundRoute.ts new file mode 100644 index 0000000000..6ce4a62e0c --- /dev/null +++ b/apps/expo-app/src/playground/PlaygroundRoute.ts @@ -0,0 +1,6 @@ +import type React from 'react'; + +export type PlaygroundRoute = { + key: string; + getComponent: () => React.ComponentType; +}; diff --git a/apps/expo-app/src/playground/index.ts b/apps/expo-app/src/playground/index.ts new file mode 100644 index 0000000000..b163092b89 --- /dev/null +++ b/apps/expo-app/src/playground/index.ts @@ -0,0 +1,2 @@ +export { Playground } from './Playground'; +export type { PlaygroundRoute } from './PlaygroundRoute'; diff --git a/packages/ui-mobile-playground/src/components/keyToRouteName.ts b/apps/expo-app/src/playground/keyToRouteName.ts similarity index 100% rename from packages/ui-mobile-playground/src/components/keyToRouteName.ts rename to apps/expo-app/src/playground/keyToRouteName.ts diff --git a/apps/expo-app/src/playground/types.ts b/apps/expo-app/src/playground/types.ts new file mode 100644 index 0000000000..a62d3d9f05 --- /dev/null +++ b/apps/expo-app/src/playground/types.ts @@ -0,0 +1,28 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; + +type RouteParams = { routeKeys: string[] } | undefined; + +export type PlaygroundStackParamList = { + DebugExamples: { routeKeys: string[] }; + DebugSearch: { routeKeys: string[] }; + DebugIconSheet: undefined; +} & { + [key: string]: RouteParams; +}; + +export type ExamplesListScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugExamples' | 'DebugSearch' +>; + +export type IconSheetScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugIconSheet' +>; + +declare global { + namespace ReactNavigation { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface RootParamList extends PlaygroundStackParamList {} + } +} diff --git a/apps/mobile-app/src/routes.ts b/apps/expo-app/src/routes.ts similarity index 93% rename from apps/mobile-app/src/routes.ts rename to apps/expo-app/src/routes.ts index ab6ce35e3d..56d9f29792 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/expo-app/src/routes.ts @@ -1,6 +1,6 @@ /** * DO NOT MODIFY - * Generated from scripts/codegen/main.ts + * Generated from libs/codegen */ export const routes = [ { @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => @@ -62,7 +67,7 @@ export const routes = [ { key: 'AreaChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/area/__stories__/AreaChart.stories') .default, }, { @@ -77,7 +82,7 @@ export const routes = [ { key: 'Axis', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/axis/__stories__/Axis.stories').default, }, { key: 'Banner', @@ -96,7 +101,7 @@ export const routes = [ { key: 'BarChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/bar/__stories__/BarChart.stories').default, }, { key: 'Box', @@ -134,27 +139,22 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, }, - { - key: 'CarouselMedia', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, - }, { key: 'CartesianChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/CartesianChart.stories') .default, }, { key: 'ChartAccessibility', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/ChartAccessibility.stories') .default, }, { key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/ChartTransitions.stories') .default, }, { @@ -347,7 +347,8 @@ export const routes = [ { key: 'Legend', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/legend/__stories__/Legend.stories') + .default, }, { key: 'LinearGradient', @@ -357,7 +358,7 @@ export const routes = [ { key: 'LineChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/line/__stories__/LineChart.stories') .default, }, { @@ -528,13 +529,13 @@ export const routes = [ { key: 'PercentageBarChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/PercentageBarChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/bar/__stories__/PercentageBarChart.stories') .default, }, { key: 'PeriodSelector', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/PeriodSelector.stories') .default, }, { @@ -575,7 +576,7 @@ export const routes = [ { key: 'ReferenceLine', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') + require('@coinbase/cds-mobile/visualizations/chart/line/__stories__/ReferenceLine.stories') .default, }, { @@ -596,7 +597,7 @@ export const routes = [ { key: 'Scrubber', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + require('@coinbase/cds-mobile/visualizations/chart/scrubber/__stories__/Scrubber.stories') .default, }, { @@ -640,24 +641,25 @@ export const routes = [ { key: 'Sparkline', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, + require('@coinbase/cds-mobile/visualizations/sparkline/__stories__/Sparkline.stories') + .default, }, { key: 'SparklineGradient', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/__stories__/SparklineGradient.stories') .default, }, { key: 'SparklineInteractive', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') .default, }, { key: 'SparklineInteractiveHeader', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') .default, }, { diff --git a/apps/mobile-app/tsconfig.json b/apps/expo-app/tsconfig.json similarity index 73% rename from apps/mobile-app/tsconfig.json rename to apps/expo-app/tsconfig.json index 1352a8ed5f..754926c77e 100644 --- a/apps/mobile-app/tsconfig.json +++ b/apps/expo-app/tsconfig.json @@ -13,13 +13,10 @@ }, "include": [ "src/**/*", + "*.tsx", "*.config.js", "*.config.ts", - "scripts", - ".eslintrc.cjs", - "*.d.ts", - "*.json", - "e2e" + "*.json" ], "exclude": [], "references": [ @@ -34,12 +31,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/ui-mobile-playground" - }, - { - "path": "../../packages/mobile-visualization" } ] } diff --git a/apps/mobile-app/README.md b/apps/mobile-app/README.md deleted file mode 100644 index 373560ac0b..0000000000 --- a/apps/mobile-app/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Mobile App - -This is a playground for mobile component development. It uses `packages/ui-mobile-playground` to manage the UI components that render the mobile storybook components. This app is primarily expo logic that wraps and renders `ui-mobile-playground` components. - -## When to use Expo Go vs Expo Prebuilds? - -**[Expo Go](#expo-go)** (Recommended for most cases) - -Expo Go enables fast development and testing directly on devices with over-the-air updates from your dev server. - -- Recommended for most development -- For testing on physical device - -**[Expo Prebuilds](./docs/prebuilds.md)** - -Expo prebuilds generate full native iOS and Android binaries with embedded JS bundle for testing custom native code. - -- Required if you made dependency changes, like updating React Native -- Needed for testing any custom native modules and integrations - -## Expo Go - -1. Run `yarn install` from root - -2. Start the [expo development server](https://docs.expo.dev/more/expo-cli/#develop) by running: - -``` -yarn nx run mobile-app:go -``` - -### Run the app in the iOS/Android simulator - -Press 'i' or 'a' to open iOS or Android simulator respectively. - -### Run the app on a physical device - -> For security reasons, please make sure your device has Coinbase Security Profile installed before proceeding. - -1. Download [Expo Go](https://expo.dev/client) on your device. -2. Scan the QR code from the terminal using the Expo Go app on your phone. -3. Make sure your device and metro server are connected to the same network. You might need to disconnect VPN. - -## Creating a new route - -Whenever you want to add a new screen to the mobile-app, you'll need to run this codegen script to generate the new route(s). - -```zsh -yarn nx run codegen:mobile-routes -``` - -## Advanced - - - -- [How to generate prebuilds](./docs/prebuilds.md) -- [How to upgrade React Native version](./docs/upgrade-rn.md) -- [How and when to create new mobile build](./docs/building-mobile.md) -- [How to upgrade a native dependency](./docs/upgrading-mobile-dep.md) -- [How to debug failures & common errors](./docs/help.md) diff --git a/apps/mobile-app/app.config.ts b/apps/mobile-app/app.config.ts deleted file mode 100644 index 00cfabf2ba..0000000000 --- a/apps/mobile-app/app.config.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { getExpoSDKVersion } from '@expo/config'; -import { withProjectBuildGradle } from '@expo/config-plugins'; -import type { ExpoConfig } from '@expo/config-types'; - -const profile = process.env.APP_PROFILE ?? ('debug' as const); -const jsEngine = process.env.APP_JS_ENGINE ?? ('hermes' as const); -const newArchEnabled = process.env.APP_NEW_ARCH_ENABLED === '1'; -const bundleIdentifier = process.env.APP_IOS_BUNDLE_IDENTIFIER ?? 'com.ui-systems.debug-ios-hermes'; -const packageIdentifier = - process.env.APP_ANDROID_PACKAGE_IDENTIFIER ?? 'com.ui_systems.debug_hermes'; - -const lookupKey = `${profile}-${jsEngine}` as const; -const iconName = `icon-${lookupKey}` as const; -const splashName = `splash-${lookupKey}` as const; -const splashColor = { - 'debug-jsc': '#44C28D', - 'debug-hermes': '#D058C1', - 'release-jsc': '#E7C95B', - 'release-hermes': '#06BEEC', -}[lookupKey]; - -const expo: ExpoConfig = { - name: 'CDS', - slug: 'cds', // we might need to change so it's unique across builds for deep linking - scheme: 'cds', - owner: 'ui-systems', - extra: {}, - runtimeVersion: { - policy: 'sdkVersion', - }, - orientation: 'default' as const, - icon: `./assets/${iconName}.png`, - - sdkVersion: getExpoSDKVersion(__dirname), - jsEngine, - userInterfaceStyle: 'automatic' as const, - splash: { - image: `./assets/${splashName}.png`, // TODO: dynamically generate based on jsEngine https://github.com/expo/fyi/blob/main/black-screen-before-splash.md - resizeMode: 'contain', - backgroundColor: splashColor, - }, - assetBundlePatterns: ['**/*'], - ios: { - supportsTablet: true, - bundleIdentifier, - }, - android: { - adaptiveIcon: { - foregroundImage: './assets/adaptive-icon.png', - backgroundColor: splashColor, - }, - package: packageIdentifier, - }, - plugins: [ - [ - 'expo-build-properties', - { - ios: { - newArchEnabled, - }, - android: { - kotlinVersion: '1.8.0', - newArchEnabled, - /** - * https://docs.expo.dev/build-reference/e2e-tests/#51-patch-buildgradle - * Temporary patch required until detox integration is first class - * - * The Android build command that we use to produce the test build is ./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release - * Notice that it consists of two Gradle tasks. Unfortunately, when building the *AndroidTest task, some versions of the expo-modules-core module change - * what native libraries are included in the app binary. Those settings don't work with settings for assembleRelease. - * To fix the problem, add the pickFirsts list under android.packagingOptions in your android/app/build.gradle. - * The pickFirsts property overrides the setting for your project. - */ - packagingOptions: { - /** https://docs.expo.dev/versions/latest/sdk/build-properties/#pluginconfigtypeandroidpackagingoptions */ - pickFirst: [ - 'lib/**/libc++_shared.so', - 'lib/**/libreactnativejni.so', - 'lib/**/libreact_nativemodule_core.so', - 'lib/**/libglog.so', - 'lib/**/libjscexecutor.so', - 'lib/**/libfbjni.so', - 'lib/**/libfolly_json.so', - 'lib/**/libfolly_runtime.so', - 'lib/**/libhermes.so', - 'lib/**/libjsi.so', - ], - }, - }, - }, - ], - '@config-plugins/detox', - [ - 'expo-gradle-ext-vars', - { - androidXBrowser: '1.5.0', - }, - ], - ], -}; - -export default { - // TODO(cds-v9): remove this Gradle resolution override. - expo: withProjectBuildGradle(expo, (config) => { - config.modResults.contents += ` -subprojects { - configurations.all { - resolutionStrategy { - force 'androidx.annotation:annotation:1.9.1' - force 'androidx.annotation:annotation-jvm:1.9.1' - } - } -} -`; - return config; - }), -}; diff --git a/apps/mobile-app/assets/adaptive-icon.png b/apps/mobile-app/assets/adaptive-icon.png deleted file mode 100644 index 7f442f72c0..0000000000 Binary files a/apps/mobile-app/assets/adaptive-icon.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-debug-hermes.png b/apps/mobile-app/assets/icon-debug-hermes.png deleted file mode 100644 index 146558c347..0000000000 Binary files a/apps/mobile-app/assets/icon-debug-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-debug-jsc.png b/apps/mobile-app/assets/icon-debug-jsc.png deleted file mode 100644 index c885f43f4c..0000000000 Binary files a/apps/mobile-app/assets/icon-debug-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-release-hermes.png b/apps/mobile-app/assets/icon-release-hermes.png deleted file mode 100644 index 4327432bfe..0000000000 Binary files a/apps/mobile-app/assets/icon-release-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-release-jsc.png b/apps/mobile-app/assets/icon-release-jsc.png deleted file mode 100644 index 6cfb1dd33f..0000000000 Binary files a/apps/mobile-app/assets/icon-release-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-debug-hermes.png b/apps/mobile-app/assets/splash-debug-hermes.png deleted file mode 100644 index 5a23726ec1..0000000000 Binary files a/apps/mobile-app/assets/splash-debug-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-debug-jsc.png b/apps/mobile-app/assets/splash-debug-jsc.png deleted file mode 100644 index 6aead00d11..0000000000 Binary files a/apps/mobile-app/assets/splash-debug-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-release-hermes.png b/apps/mobile-app/assets/splash-release-hermes.png deleted file mode 100644 index 027c8a8e7f..0000000000 Binary files a/apps/mobile-app/assets/splash-release-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-release-jsc.png b/apps/mobile-app/assets/splash-release-jsc.png deleted file mode 100644 index d6459011ff..0000000000 Binary files a/apps/mobile-app/assets/splash-release-jsc.png and /dev/null differ diff --git a/apps/mobile-app/babel.config.js b/apps/mobile-app/babel.config.js deleted file mode 100644 index 5158e56cd1..0000000000 --- a/apps/mobile-app/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function getBabelConfig(api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: ['transform-inline-environment-variables'], - }; -}; diff --git a/apps/mobile-app/credentials.json b/apps/mobile-app/credentials.json deleted file mode 100644 index beff010add..0000000000 --- a/apps/mobile-app/credentials.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "android": { - "keystore": { - "keystorePath": "credentials/android-release-hermes.keystore", - "keyAlias": "detoxkey", - "keystorePassword": "android", - "keyPassword": "android" - } - } -} diff --git a/apps/mobile-app/credentials/android-release-hermes.keystore b/apps/mobile-app/credentials/android-release-hermes.keystore deleted file mode 100644 index ff8e68ece8..0000000000 Binary files a/apps/mobile-app/credentials/android-release-hermes.keystore and /dev/null differ diff --git a/apps/mobile-app/detox.config.js b/apps/mobile-app/detox.config.js deleted file mode 100644 index 261df78e9e..0000000000 --- a/apps/mobile-app/detox.config.js +++ /dev/null @@ -1,75 +0,0 @@ -function isGithubActions() { - return !!process.env.GITHUB_ACTIONS; -} - -function isCI() { - return !!process.env.CI || isGithubActions(); -} - -/** - * TODO: handle config automatically based on eas build profiles - */ -/** @type {Detox.DetoxConfig} */ -const config = { - testRunner: { - args: { - $0: 'jest', - config: 'e2e/jest.config.js', - }, - }, - apps: { - 'ios-debug': { - type: 'ios.app', - binaryPath: 'prebuilds/ios-debug-hermes.app', - }, - 'android-debug': { - type: 'android.apk', - binaryPath: 'prebuilds/android-debug-hermes/binary.apk', - testBinaryPath: 'prebuilds/android-debug-hermes/testBinary.apk', - }, - 'ios-release': { - type: 'ios.app', - binaryPath: 'prebuilds/ios-release-hermes.app', - }, - 'android-release': { - type: 'android.apk', - binaryPath: 'prebuilds/android-release-hermes/binary.apk', - testBinaryPath: 'prebuilds/android-release-hermes/testBinary.apk', - }, - }, - devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 16', - }, - }, - emulator: { - type: 'android.emulator', - device: { - avdName: isCI() ? 'cds_detox' : 'cds_detox_local', - }, - bootArgs: isCI() ? '-skin 600x5000' : undefined, - }, - }, - configurations: { - 'ios-debug': { - device: 'simulator', - app: 'ios-debug', - }, - 'android-debug': { - device: 'emulator', - app: 'android-debug', - }, - 'ios-release': { - device: 'simulator', - app: 'ios-release', - }, - 'android-release': { - device: 'emulator', - app: 'android-release', - }, - }, -}; - -module.exports = config; diff --git a/apps/mobile-app/env.d.ts b/apps/mobile-app/env.d.ts deleted file mode 100644 index 14c38d5e16..0000000000 --- a/apps/mobile-app/env.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/// - -/* eslint-disable no-restricted-syntax */ - -declare module 'process' { - global { - export const testFailed: boolean; - namespace NodeJS { - interface ProcessEnv { - readonly APP_DEBUG?: `${0 | 1}`; - readonly APP_JS_ENGINE?: 'jsc' | 'hermes'; - readonly APP_PLATFORM: 'ios' | 'android'; - readonly APP_NAME?: string; - readonly APP_NEW_ARCH_ENABLED?: `${0 | 1}`; - readonly APP_PROFILE?: 'debug' | 'release'; - } - } - } -} diff --git a/apps/mobile-app/index.js b/apps/mobile-app/index.js deleted file mode 100644 index 4f5df0f978..0000000000 --- a/apps/mobile-app/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import 'react-native-gesture-handler'; -import './src/polyfills/intl'; - -import { registerRootComponent } from 'expo'; -import * as SplashScreen from 'expo-splash-screen'; - -import App from './src/App'; - -// It is recommended to call this in global scope without awaiting, rather than inside React components or hooks, -// because otherwise this might be called too late, when the splash screen is already hidden. -SplashScreen.preventAutoHideAsync(); - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); diff --git a/apps/mobile-app/jest.config.js b/apps/mobile-app/jest.config.js deleted file mode 100644 index 0ddaf11c1f..0000000000 --- a/apps/mobile-app/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - preset: '../../jest.preset.js', -}; diff --git a/apps/mobile-app/metro.config.js b/apps/mobile-app/metro.config.js deleted file mode 100644 index 78e709847d..0000000000 --- a/apps/mobile-app/metro.config.js +++ /dev/null @@ -1,118 +0,0 @@ -const exclusionList = require('metro-config/src/defaults/exclusionList'); -const { resolve } = require('metro-resolver'); -const { getDefaultConfig } = require('expo/metro-config'); -const { mergeConfig } = require('@react-native/metro-config'); -const path = require('node:path'); -const { resolve: resolveExports } = require('resolve.exports'); - -// Learn more https://docs.expo.io/guides/customizing-metro -const expoConfig = getDefaultConfig(__dirname); -const defaultSourceExts = ['ts', 'tsx', 'js', 'jsx', 'json', 'd.ts', 'cjs']; - -const aliases = { - '@coinbase/cds-common': path.resolve(__dirname, '../../packages/common/src'), - '@coinbase/cds-icons': path.resolve(__dirname, '../../packages/icons/src'), - '@coinbase/cds-illustrations': path.resolve(__dirname, '../../packages/illustrations/src'), - '@coinbase/cds-lottie-files': path.resolve(__dirname, '../../packages/lottie-files/src'), - '@coinbase/cds-mobile': path.resolve(__dirname, '../../packages/mobile/src'), - '@coinbase/cds-mobile-visualization': path.resolve( - __dirname, - '../../packages/mobile-visualization/src', - ), - '@coinbase/cds-utils': path.resolve(__dirname, '../../packages/utils/src'), - '@coinbase/ui-mobile-playground': path.resolve( - __dirname, - '../../packages/ui-mobile-playground/src', - ), -}; -const pkgCache = {}; - -const getBaseModule = (moduleName) => { - const parts = moduleName.split('/'); - if (!moduleName.startsWith('@')) return parts[0]; - return `${parts[0]}/${parts[1]}`; -}; - -function loadPackageJson(pkgPath) { - if (!pkgCache[pkgPath]) { - pkgCache[pkgPath] = require(pkgPath); - } - - return pkgCache[pkgPath]; -} - -// This custom Metro resolver will try to use the aliases defined above. -const customResolveRequest = (context, baseModuleName, platform) => { - const { resolveRequest: resolveRequestInner, ...ctx } = context; - - const moduleName = context.redirectModulePath(baseModuleName); - const baseModule = moduleName && getBaseModule(moduleName); - - // Custom resolver to map local package aliases to exports - if ( - process.env.CI !== 'true' && - process.env.NODE_ENV !== 'production' && - process.env.CDS_METRO_RESOLVER !== 'false' && - baseModule && - aliases[baseModule] - ) { - const aliasPath = moduleName.replace(baseModule, aliases[baseModule]); - return context.resolveRequest(context, aliasPath, platform); - } - - if (moduleName === false) { - return { - type: 'empty', - }; - } - - /** - * This custom resolver checks for an "exports" field in package.json and resolves accordingly. - * NOTE: This mimics the behavior of unstable_enablePackageExports which is unable to be used by CDS for some reason. - */ - if (moduleName.startsWith('@cb')) { - const pkgPath = require.resolve(`${getBaseModule(moduleName)}/package.json`); - const pkg = loadPackageJson(pkgPath); - - if ('exports' in pkg) { - const entryPoint = resolveExports(pkg, moduleName, { - conditions: ['react-native', 'browser', 'module', 'require', 'node', 'default'], - unsafe: true, - }); - - if (entryPoint) { - return { - filePath: path.join(path.dirname(pkgPath), String(entryPoint)), - type: 'sourceFile', - }; - } - } - } - - if (resolveRequestInner) { - // Nothing found, fallback to metro - return resolveRequestInner(context, moduleName, platform); - } - - return resolve(ctx, moduleName, platform); -}; - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const metroConfig = mergeConfig(expoConfig, { - resetCache: true, - resolver: { - blacklistRE: exclusionList([/dist\/@cb\/.*/]), - // https://github.com/wix/Detox/blob/master/docs/Guide.Mocking.md#Configuration - sourceExts: process.env.RN_SRC_EXT - ? process.env.RN_SRC_EXT.split(',').concat(defaultSourceExts) - : defaultSourceExts, - resolveRequest: customResolveRequest, - }, -}); - -module.exports = metroConfig; diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json deleted file mode 100644 index 30263a96ad..0000000000 --- a/apps/mobile-app/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "mobile-app", - "version": "0.0.0", - "private": true, - "scripts": { - "go": "expo start --go", - "start": "expo start --dev-client", - "android": "expo run:android", - "ios": "expo run:ios", - "prebuild-install": "cd ../../ && yarn install && yarn nx run mobile-app:setup", - "should-run-visreg": "node ./scripts/utils/shouldRunVisreg.mjs" - }, - "dependencies": { - "@bugsnag/react-native": "^7.18.0", - "@coinbase/cds-common": "workspace:^", - "@coinbase/cds-icons": "workspace:^", - "@coinbase/cds-illustrations": "workspace:^", - "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", - "@coinbase/ui-mobile-playground": "workspace:^", - "@config-plugins/detox": "^6.0.0", - "@expo-google-fonts/inter": "^0.3.0", - "@expo-google-fonts/source-code-pro": "^0.3.0", - "@formatjs/intl-getcanonicallocales": "^2.5.5", - "@formatjs/intl-locale": "^4.2.11", - "@formatjs/intl-numberformat": "^8.15.4", - "@formatjs/intl-pluralrules": "^5.4.4", - "@react-native/metro-config": "^0.72.9", - "@react-navigation/core": "^6.4.16", - "@react-navigation/native": "^6.1.6", - "@react-navigation/native-stack": "^6.9.26", - "@react-navigation/stack": "^6.3.16", - "@shopify/react-native-skia": "1.12.4", - "expo": "~51.0.31", - "expo-application": "~5.9.1", - "expo-asset": "~10.0.10", - "expo-build-properties": "~0.12.5", - "expo-clipboard": "~6.0.3", - "expo-dev-client": "4.0.27", - "expo-font": "~12.0.9", - "expo-gradle-ext-vars": "^0.1.1", - "expo-linking": "~6.3.1", - "expo-quick-actions": "2.0.0", - "expo-splash-screen": "~0.27.6", - "expo-status-bar": "~1.12.1", - "expo-system-ui": "~3.0.7", - "intl": "^1.2.5", - "lottie-react-native": "6.7.0", - "react": "^18.3.1", - "react-native": "0.74.5", - "react-native-gesture-handler": "2.16.2", - "react-native-inappbrowser-reborn": "3.7.0", - "react-native-navigation-bar-color": "2.0.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.32.0", - "react-native-svg": "14.1.0" - }, - "devDependencies": { - "@babel/core": "^7.28.0", - "@expo/config": "~9.0.0", - "@expo/config-types": "~51.0.2", - "@types/react": "^18.3.12", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", - "detox": "^20.14.8", - "jest": "^29.7.0", - "react-native-bundle-visualizer": "^3.1.3", - "zx": "^8.1.9" - } -} diff --git a/apps/mobile-app/prebuilds/android-release-hermes.zip b/apps/mobile-app/prebuilds/android-release-hermes.zip deleted file mode 100644 index 84eee2c3fb..0000000000 Binary files a/apps/mobile-app/prebuilds/android-release-hermes.zip and /dev/null differ diff --git a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz b/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz deleted file mode 100644 index 003859b2aa..0000000000 Binary files a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz and /dev/null differ diff --git a/apps/mobile-app/project.json b/apps/mobile-app/project.json deleted file mode 100644 index 8a30e68170..0000000000 --- a/apps/mobile-app/project.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "name": "mobile-app", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/mobile-app/src", - "projectType": "application", - "tags": [], - "targets": { - "setup": { - "executor": "nx:run-commands", - "dependsOn": [ - "^build" - ], - "options": { - "cwd": "apps/mobile-app", - "commands": [ - "mkdir -p ios", - "mkdir -p android" - ] - } - }, - "launch": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/launch.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios" - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android" - } - } - }, - "start": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/start.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - } - } - }, - "build": { - "executor": "nx:run-commands", - "dependsOn": [], - "options": { - "cwd": "apps/mobile-app", - "command": "export CDS_METRO_RESOLVER=false && node ./scripts/build.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios", - "dependsOn": [ - "^build" - ] - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android", - "dependsOn": [ - "^build" - ] - } - } - }, - "detox": { - "executor": "nx:run-commands", - "dependsOn": [ - "^build" - ], - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/detox.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}", - "env": { - "DETOX_TEST": "true" - } - }, - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios" - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android" - } - } - }, - "validate": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/validate.mjs" - } - }, - "patch-bundle-ios": { - "command": "node ./scripts/patch-bundle.mjs --platform ios --profile release --jsEngine hermes", - "options": { - "cwd": "apps/mobile-app" - } - }, - "patch-bundle-android": { - "command": "node ./scripts/patch-bundle.mjs --platform android --profile release --jsEngine hermes", - "options": { - "cwd": "apps/mobile-app" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "{projectRoot}/jest.config.js" - } - }, - "typecheck": { - "command": "tsc --build --pretty --verbose" - } - } -} diff --git a/apps/mobile-app/react-native.config.js b/apps/mobile-app/react-native.config.js deleted file mode 100644 index f053ebf797..0000000000 --- a/apps/mobile-app/react-native.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/apps/mobile-app/scripts/build.mjs b/apps/mobile-app/scripts/build.mjs deleted file mode 100644 index 24a4ad87a3..0000000000 --- a/apps/mobile-app/scripts/build.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { $, argv, fs } from 'zx'; - -import { buildAndroid } from './utils/buildAndroid.mjs'; -import { buildIOS } from './utils/buildIOS.mjs'; -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform, profile } = argv; -const { ios, android, outputDirectory } = getBuildInfo(); - -setEnvVars(); - -// Ensure output directory exists -await fs.ensureDir(outputDirectory); - -// Run prebuild to generate native projects -console.log(`Running prebuild for ${platform}...`); -await $`npx expo prebuild --platform ${platform} --clean`; - -// Build for the specific platform -if (platform === 'ios') { - await buildIOS({ profile, ios }); - await ios.unzip(); -} - -if (platform === 'android') { - await buildAndroid({ profile, android }); - await android.unzip(); -} diff --git a/apps/mobile-app/scripts/detox.mjs b/apps/mobile-app/scripts/detox.mjs deleted file mode 100644 index bb9972f7d8..0000000000 --- a/apps/mobile-app/scripts/detox.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import { $, argv, within } from 'zx'; // https://github.com/google/zx - -import detoxConfig from '../detox.config.js'; - -import { isCI } from './utils/env.mjs'; -import { getAffectedRoutes } from './utils/getAffectedRoutes.mjs'; -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform, profile, jsEngine } = argv; - -const { commonChanged, affectedRouteKeys } = await getAffectedRoutes(); - -const runAll = process.env.DETOX_RUN_ALL === 'true'; - -// Only run detox if packages/common or code in a route's parent directory changed, or DETOX_RUN_ALL is true -if (!runAll && !commonChanged && !affectedRouteKeys.length) { - console.log('No relevant changes to test, skipping detox'); - process.exit(0); -} - -// Set the affected route keys for playgroundRoutes.e2e.ts, and flag as Percy partial build -if (!runAll && !commonChanged) { - console.log('Only testing routes affected by changes:', affectedRouteKeys); - process.env.DETOX_AFFECTED_ROUTE_KEYS = affectedRouteKeys.join(','); - process.env.PERCY_PARTIAL_BUILD = '1'; -} else console.log('Testing all routes'); - -const { ios, android } = getBuildInfo(); - -setEnvVars(); - -if (platform === 'android') { - const targetAvd = detoxConfig.devices.emulator.device.avdName; - const { stdout: platformsAsString } = await $`ls ${process.env.ANDROID_SDK_ROOT}/platforms`; - const { stdout: buildToolsAsString } = await $`ls ${process.env.ANDROID_SDK_ROOT}/build-tools`; - const { stdout: emulatorsAsString } = await $`avdmanager list avd --compact`; - const platforms = platformsAsString.split('\n'); - const buildTools = buildToolsAsString.split('\n'); - const emulators = emulatorsAsString.split('\n'); - const doesNotHavePlatform = !platforms.includes(`android-${android.sdkVersions.platform}`); - const doesNotHaveBuildTools = !buildTools.includes(android.sdkVersions.buildTools); - const doesNotHaveEmulator = !emulators.includes(targetAvd); - - if (doesNotHavePlatform) { - await $`sdkmanager "platforms;android-${android.sdkVersions.platform}"`; - } - - if (doesNotHaveBuildTools) { - await $`sdkmanager "build-tools;${android.sdkVersions.buildTools}"`; - } - - if (doesNotHaveEmulator) { - const architecture = isCI ? android.architectures.ubuntu : android.architectures.m1; - const androidSdk = `system-images;android-${android.sdkVersions.systemImage};default;${architecture}`; - - await $`sdkmanager ${androidSdk}`; - await $`echo no | avdmanager create avd --force --name ${targetAvd} --package ${androidSdk}`; - } -} - -if (profile === 'debug') { - within(async () => { - await $`cd ../../ && yarn nx run mobile-app:launch --profile ${profile} --jsEngine ${jsEngine} --platform ${platform}`; - await $`cd ../../ && yarn nx run mobile-app:start --profile ${profile} --jsEngine ${jsEngine} --platform ${platform}`; - }); -} - -if (profile === 'release') { - if (platform === 'android') await android.patchBundle(); - if (platform === 'ios') await ios.patchBundle(); -} - -// Rebuild Detox cache on MacOS to mitigate errors from Xcode updates -if (platform === 'ios') { - await $`yarn workspace mobile-app detox rebuild-framework-cache`; -} - -// Clear Jest cache -await $`yarn workspace mobile-app detox test --configuration ${platform}-${profile} --clearCache`; - -const platformOptions = platform === 'android' ? '--force-adb-install' : ''; - -if (isCI) { - await $`yarn workspace mobile-app detox test --configuration ${platform}-${profile} --headless --cleanup ${platformOptions}`; -} else { - await $`yarn workspace mobile-app detox test --loglevel verbose --configuration ${platform}-${profile} --cleanup ${platformOptions}`; -} diff --git a/apps/mobile-app/scripts/launch.mjs b/apps/mobile-app/scripts/launch.mjs deleted file mode 100644 index 8af226013e..0000000000 --- a/apps/mobile-app/scripts/launch.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { android, ios } = getBuildInfo(); -const { platform } = argv; - -setEnvVars(); - -const archivePath = platform === 'android' ? android.apk.signed : ios.app; - -if (platform === 'android') { - await android.patchBundle(); -} - -if (platform === 'ios') { - await ios.patchBundle(); -} - -// Install and run the built app using platform-specific tools -if (platform === 'ios') { - await $`xcrun simctl install booted ${archivePath}`; - const bundleId = ios.bundleIdentifier; - await $`xcrun simctl launch booted ${bundleId}`; -} else { - // For Android, install the APK - await $`adb install ${archivePath}`; - // Launch the app - const packageId = android.packageIdentifier; - await $`adb shell monkey -p ${packageId} -c android.intent.category.LAUNCHER 1`; -} diff --git a/apps/mobile-app/scripts/patch-bundle.mjs b/apps/mobile-app/scripts/patch-bundle.mjs deleted file mode 100644 index 52725f70df..0000000000 --- a/apps/mobile-app/scripts/patch-bundle.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { argv } from 'zx'; - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -setEnvVars(); -const { ios, android } = getBuildInfo(); - -if (argv.platform === 'ios') await ios.patchBundle(); -if (argv.platform === 'android') await android.patchBundle(); diff --git a/apps/mobile-app/scripts/start.mjs b/apps/mobile-app/scripts/start.mjs deleted file mode 100644 index e1ad5d0aaa..0000000000 --- a/apps/mobile-app/scripts/start.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform } = argv; -const { ios } = getBuildInfo(); - -setEnvVars(); - -if (platform === 'ios') { - await $`expo start --${argv.platform} --dev-client --localhost --scheme ${ios.bundleIdentifier}`; -} - -if (platform === 'android') { - await $`expo start --${argv.platform} --dev-client --localhost`; -} diff --git a/apps/mobile-app/scripts/utils/apktool.jar b/apps/mobile-app/scripts/utils/apktool.jar deleted file mode 100644 index 24a539a247..0000000000 Binary files a/apps/mobile-app/scripts/utils/apktool.jar and /dev/null differ diff --git a/apps/mobile-app/scripts/utils/buildAndroid.mjs b/apps/mobile-app/scripts/utils/buildAndroid.mjs deleted file mode 100644 index 6535cb5f7f..0000000000 --- a/apps/mobile-app/scripts/utils/buildAndroid.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import path from 'path'; -import { $, fs } from 'zx'; - -export async function buildAndroid({ profile, android }) { - const androidProjectPath = path.resolve('android'); - const isDebug = profile === 'debug'; - const buildType = isDebug ? 'Debug' : 'Release'; - - console.log(`Building Android app with build type: ${buildType}`); - - // Set up gradle command based on profile - let gradleTasks; - if (isDebug) { - gradleTasks = [':app:assembleDebug', ':app:assembleAndroidTest', '-DtestBuildType=debug']; - } else { - gradleTasks = [':app:assembleRelease', ':app:assembleAndroidTest', '-DtestBuildType=release']; - } - - // Build the APK - await $`cd ${androidProjectPath} && ./gradlew ${gradleTasks} --no-daemon`; - - // Find the built APKs - const buildOutputDir = path.join(androidProjectPath, 'app', 'build', 'outputs', 'apk'); - const testOutputDir = path.join(buildOutputDir, 'androidTest', profile); - const appOutputDir = path.join(buildOutputDir, profile); - - // Create output directory for our builds - const outputDir = path.dirname(android.zipFile); - await fs.ensureDir(outputDir); - - // Create a temporary directory for the zip contents - const tempDir = path.join(outputDir, 'temp'); - await fs.ensureDir(tempDir); - - // Create the expected directory structure for the zip - const testFolder = path.join(tempDir, 'androidTest', profile); - const buildFolder = path.join(tempDir, profile); - await fs.ensureDir(testFolder); - await fs.ensureDir(buildFolder); - - // Copy APKs to the temporary directory with expected names - const appApkName = `app-${profile}.apk`; - const testApkName = `app-${profile}-androidTest.apk`; - - // Find and copy the actual APK files - const appApkSource = path.join(appOutputDir, appApkName); - const testApkSource = path.join(testOutputDir, testApkName); - - await fs.copy(appApkSource, path.join(buildFolder, appApkName)); - await fs.copy(testApkSource, path.join(testFolder, testApkName)); - - // Create the zip file (tar format to match the original unzip logic) - console.log(`Creating archive: ${android.zipFile}`); - await $`cd ${tempDir} && zip -r ${path.resolve(android.zipFile)} .`; - - // Clean up temporary directory - await $`rm -rf ${tempDir}`; - - console.log(`Android build completed: ${android.zipFile}`); -} diff --git a/apps/mobile-app/scripts/utils/buildIOS.mjs b/apps/mobile-app/scripts/utils/buildIOS.mjs deleted file mode 100644 index 0cfcc31da3..0000000000 --- a/apps/mobile-app/scripts/utils/buildIOS.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import path from 'path'; -import { $, fs } from 'zx'; - -export async function buildIOS({ profile, ios }) { - const buildConfiguration = profile === 'debug' ? 'Debug' : 'Release'; - const iosProjectPath = path.resolve('ios'); - const workspacePath = path.join(iosProjectPath, 'CDS.xcworkspace'); - const scheme = 'CDS'; - - console.log(`Building iOS app with configuration: ${buildConfiguration}`); - - // Build directory for output - const buildDir = path.resolve('build'); - await fs.ensureDir(buildDir); - - // Build command for simulator - await $`xcodebuild -workspace ${workspacePath} -scheme ${scheme} -configuration ${buildConfiguration} -derivedDataPath ./build -destination 'generic/platform=iOS Simulator' build`; - - // Find the built app - const appPath = path.join( - buildDir, - 'Build', - 'Products', - `${buildConfiguration}-iphonesimulator`, - 'CDS.app', - ); - - // Create tarball - console.log(`Creating tarball: ${ios.tarball}`); - await $`cd ${path.dirname(appPath)} && tar -czf ${path.resolve(ios.tarball)} ${path.basename( - appPath, - )}`; - - // Clean up build directory - await $`rm -rf ${buildDir}`; - - console.log(`iOS build completed: ${ios.tarball}`); -} diff --git a/apps/mobile-app/scripts/utils/env.mjs b/apps/mobile-app/scripts/utils/env.mjs deleted file mode 100644 index 3753b3acac..0000000000 --- a/apps/mobile-app/scripts/utils/env.mjs +++ /dev/null @@ -1 +0,0 @@ -export const isCI = process.env.CI === 'true'; diff --git a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs b/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs deleted file mode 100644 index 0e8ff9ff92..0000000000 --- a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import pkg from '../../package.json' with { type: 'json' }; - -import { routes } from './routes.mjs'; - -const IGNORE_CHANGED_FILES_REGEX = - /^((CHANGELOG|README|MIGRATION|CONTRIBUTING)(\.md)?|[^/]+\.yml|OWNERS|project\.json|[^/]+\.[dD]ockerfile|tsconfig\.json|jest\.config\.js|\.?eslint.*)$/; -const DEV_FILES_REGEX = /(\.(spec|test|figma)\.[jt]sx?(\.snap)?$)/; - -/** - * Returns an array of changed filepaths between a branch and another base branch - */ -const getChangedFilesOnBranch = (branch, baseBranch) => { - const command = `git diff --name-only ${branch} $(git merge-base ${branch} ${baseBranch})`.split( - ' ', - ); - const changedFiles = spawnSync(command.shift() ?? '', command, { encoding: 'utf8', shell: true }); - return changedFiles.stdout.split('\n').filter(Boolean); -}; - -/** - * Returns an array of workspace dependency package names - */ -const getWorkspaceDependencies = (dependencies) => - Object.entries(dependencies) - .filter(([, version]) => version.startsWith('workspace:')) - .map(([dependency]) => dependency); - -/** - * Returns a map of workspace dependencies to their directories resolved from tsconfig paths - */ -const getWorkspaceDirectoryMap = (workspaceDependencies, tsconfigPaths) => - Object.fromEntries( - workspaceDependencies.map((dependency) => { - if (!tsconfigPaths[dependency]) - throw Error(`Missing dependency in tsconfig "paths": ${dependency}`); - return [ - dependency, - tsconfigPaths[dependency].map((dependencyPath) => dependencyPath.replace('/*', '')), - ]; - }), - ); - -/** - * Returns the workspace dependency that maps to the given directory - */ -const getWorkspaceDependencyByDirectory = (workspaceDirectory, workspaceDirectoryMap) => - Object.entries(workspaceDirectoryMap).find(([, directories]) => - directories.includes(workspaceDirectory), - )[0]; - -/** - * Returns an array of objects with playground route keys and import paths - */ -const getRoutesData = (generatedRoutes) => - generatedRoutes.map((route) => ({ - key: route.key, - importPath: route.getComponent.toString().split("'")[1], - })); - -/** - * Returns an array of import paths for the changed files - */ -const getImportPathsFromFiles = (files, sourceDirectories, workspaceDirectoryMap) => - files.map((file) => { - const matchingDirectory = sourceDirectories.find((directory) => file.startsWith(directory)); - const workspaceDependency = getWorkspaceDependencyByDirectory( - matchingDirectory, - workspaceDirectoryMap, - ); - const matchingDirectoryPathsLength = matchingDirectory.split('/').length; - const filePaths = file.split('/'); - const truncatedFilepath = filePaths.slice(0, matchingDirectoryPathsLength + 1).join('/'); - return truncatedFilepath.replace(matchingDirectory, workspaceDependency); - }); - -/** - * Returns true when a changed file should impact mobile visreg. - */ -const isFileVisregRelevant = (file, sourceDirectories) => { - const matchingDirectory = sourceDirectories.find((directory) => file.startsWith(directory)); - if (!matchingDirectory) { - return false; - } - - const relativeFilePath = file.slice(matchingDirectory.length + 1); - return ( - !DEV_FILES_REGEX.test(relativeFilePath) && !IGNORE_CHANGED_FILES_REGEX.test(relativeFilePath) - ); -}; - -/** - * Returns an object with a boolean for whether the common package changed and an array of - * ui-mobile-playground route keys that were affected by the changed files. - */ -export const getAffectedRoutes = async (log = false) => { - const baseBranch = process.env.GITHUB_BASE_REF || process.env.BASE_BRANCH || 'master'; - const changedFiles = getChangedFilesOnBranch('HEAD', baseBranch); - const workspaceDependencies = getWorkspaceDependencies(pkg.dependencies); - - const MONOREPO_ROOT = process.env.PROJECT_CWD ?? process.env.NX_MONOREPO_ROOT; - const tsconfigPath = path.resolve(MONOREPO_ROOT, 'tsconfig.base.json'); - const tsconfig = (await import(tsconfigPath, { assert: { type: 'json' } })).default; - const tsconfigPaths = tsconfig.compilerOptions.paths; - - const workspaceDirectoryMap = getWorkspaceDirectoryMap(workspaceDependencies, tsconfigPaths); - const sourceDirectories = Object.values(workspaceDirectoryMap).flat(); - - const relevantChangedFiles = changedFiles.filter((file) => - isFileVisregRelevant(file, sourceDirectories), - ); - const commonChanged = relevantChangedFiles.some((file) => file.startsWith('packages/common/')); - - const affectedImportPaths = getImportPathsFromFiles( - relevantChangedFiles, - sourceDirectories, - workspaceDirectoryMap, - ); - - const routesData = getRoutesData(routes); - - const affectedRoutesData = routesData.filter((routeData) => - affectedImportPaths.some((changedImportPath) => - routeData.importPath.startsWith(changedImportPath), - ), - ); - - const affectedRouteKeys = affectedRoutesData.map((routeData) => routeData.key); - - if (log) { - console.log('changedFiles', changedFiles); - console.log('commonChanged', commonChanged); - console.log('workspaceDependencies', workspaceDependencies); - console.log('workspaceDirectoryMap', workspaceDirectoryMap); - console.log('sourceDirectories', sourceDirectories); - console.log('relevantChangedFiles', relevantChangedFiles); - console.log('affectedImportPaths', affectedImportPaths); - console.log('routesData', routesData); - console.log('affectedRoutesData', affectedRoutesData); - console.log('affectedRouteKeys', affectedRouteKeys); - } - - return { commonChanged, affectedRouteKeys }; -}; diff --git a/apps/mobile-app/scripts/utils/getBuildInfo.mjs b/apps/mobile-app/scripts/utils/getBuildInfo.mjs deleted file mode 100644 index dba2660cc0..0000000000 --- a/apps/mobile-app/scripts/utils/getBuildInfo.mjs +++ /dev/null @@ -1,142 +0,0 @@ -import path from 'node:path'; -import { $, argv, glob } from 'zx'; // https://github.com/google/zx - -import credentials from '../../credentials.json' with { type: 'json' }; - -$.verbose = true; - -const outputDirectory = 'prebuilds'; -const filePath = new URL(import.meta.url).pathname; -const scriptUtilsDirectory = path.dirname(filePath); -const { platform, profile, jsEngine, newArchEnabled = false } = argv; - -async function patchBundleForPlatform({ platform: platformParam, fileToPatch }) { - await $`expo export --output-dir lib -p ${platformParam}`; - const matches = await glob([`lib/_expo/static/js/${platformParam}/index-*`]); - if (matches.length) { - const jsBundle = matches[0]; - await $`mv ${jsBundle} ${fileToPatch}`; - await $`rm -rf lib`; - } else { - throw new Error(`Unable to find jsbundle for ${platformParam}`); - } -} - -export function getBuildInfo() { - const kebabCaseId = `${platform}-${profile}-${jsEngine}${newArchEnabled ? '-newArch' : ''}`; - const snakeCaseId = kebabCaseId.replaceAll('-', '_'); - const outputName = `${outputDirectory}/${kebabCaseId}`; - - const ios = { - tarball: `${outputName}.tar.gz`, - bundleIdentifier: `com.ui-systems.${kebabCaseId}`, - app: `${outputName}.app`, - unzip: async function unzip() { - await $`rm -rf ${this.app}`; - await $`tar -zxvf ${this.tarball}`; - await $`mv CDS.app ${this.app}`; - }, - patchBundle: async function patchBundle() { - if (process.env.SKIP_PATCH_BUNDLE) return; - await this.unzip(); - if (profile !== 'debug') { - await patchBundleForPlatform({ - platform: 'ios', - fileToPatch: `${this.app}/main.jsbundle`, - }); - } - }, - }; - - const android = { - sdkVersions: { - platform: '34', - buildTools: '35.0.0', - systemImage: '30', - }, - zipFile: `${outputName}.zip`, - packageIdentifier: `com.ui_systems.${snakeCaseId}`, - keystore: credentials.android.keystore, - apk: { - contents: `${outputName}/build`, - rebuilt: `${outputName}/binary-rebuilt.apk`, - rebuiltAligned: `${outputName}/binary-rebuilt-aligned.apk`, - signed: `${outputName}/binary.apk`, - }, - testApk: `${outputName}/testBinary.apk`, - // https://expo.canny.io/feature-requests/p/add-reactnativearchitecture-support-in-expo-build-properties - // There archs are also set in eas.json, ORG_GRADLE_PROJECT_reactNativeArchitectures env variable - architectures: { - ubuntu: 'x86_64', - m1: 'arm64-v8a', - }, - getBuildTool: async function getBuildTool(name) { - return path.join( - process.env.ANDROID_SDK_ROOT, - 'build-tools', - this.sdkVersions.buildTools, - name, - ); - }, - unzip: async function unzip() { - await $`rm -rf ${outputName}`; - await $`mkdir -p ${outputName}`; - const testFolder = `${outputName}/androidTest/${profile}`; - const buildFolder = `${outputName}/${profile}`; - await $`unzip -q ${this.zipFile} -d ${outputName}`; - await $`mv ${testFolder}/app-${profile}-androidTest.apk ${this.testApk}`; - await $`mv ${buildFolder}/app-${profile}.apk ${this.apk.signed}`; - await $`rm -rf ${path.dirname(testFolder)} && rm -rf ${buildFolder}`; - }, - /** - * What's java -jar? - * https://bitbucket.org/iBotPeaches/apktool/downloads/ - * Instead of installing apktool locally and in CI, we commit the apktool.jar and - * run executable via java -jar and point to the .jar file we commit. - * - * Why String.split? - * https://github.com/google/zx/blob/main/docs/quotes.md#array-of-arguments - * zx escapes and adds quotes to any interpolations used in a $ command. - * Because this string as command + args we have to convert it into an array of args - * to avoid it being treated as one single string when used in zx's $ template literal. - */ - apktool: `java -jar ${scriptUtilsDirectory}/apktool.jar`.split(' '), - decodeApk: async function decode() { - await this.unzip(); - await $`${this.apktool} decode -f ${this.apk.signed} --output ${this.apk.contents}`; - }, - rebuildApk: async function rebuildApk() { - const [apksigner, zipalign] = await Promise.all([ - this.getBuildTool('apksigner'), - this.getBuildTool('zipalign'), - ]); - const ksPass = `pass:${this.keystore.keystorePassword}`; - const keyPass = `pass:${this.keystore.keyPassword}`; - await $`${this.apktool} build ${this.apk.contents} --output ${this.apk.rebuilt}`; - await $`${zipalign} 4 ${this.apk.rebuilt} ${this.apk.rebuiltAligned}`; - await $`${apksigner} sign --ks ${this.keystore.keystorePath} --ks-key-alias ${this.keystore.keyAlias} --ks-pass ${ksPass} --key-pass ${keyPass} --out ${this.apk.signed} ${this.apk.rebuiltAligned}`; - await Promise.all([$`rm -rf ${this.apk.rebuilt}`, $`rm -rf ${this.apk.rebuiltAligned}`]); - }, - patchBundle: async function patchBundle() { - if (process.env.SKIP_PATCH_BUNDLE) return; - if (profile === 'debug') { - await this.unzip(); - return; - } - - await this.decodeApk(); - await patchBundleForPlatform({ - platform: 'android', - fileToPatch: `${this.apk.contents}/assets/index.android.bundle`, - }); - await this.rebuildApk(); - await $`rm -rf ${this.apk.contents}`; - }, - }; - - return { - ios, - android, - outputDirectory, - }; -} diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs deleted file mode 100644 index ac70f70b4d..0000000000 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ /dev/null @@ -1,910 +0,0 @@ -/** - * DO NOT MODIFY - * Generated from scripts/codegen/main.ts - */ -export const routes = [ - { - key: 'Accordion', - getComponent: () => - require('@coinbase/cds-mobile/accordion/__stories__/Accordion.stories').default, - }, - { - key: 'AlertBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertBasic.stories').default, - }, - { - key: 'AlertLongTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertLongTitle.stories').default, - }, - { - key: 'AlertOverModal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertOverModal.stories').default, - }, - { - key: 'AlertPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertPortal.stories').default, - }, - { - key: 'AlertSingleAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertSingleAction.stories').default, - }, - { - key: 'AlertVerticalActions', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, - }, - { - key: 'AlphaSelect', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, - }, - { - key: 'AlphaSelectChip', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, - }, - { - key: 'AlphaTabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') - .default, - }, - { - key: 'AnimatedCaret', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, - }, - { - key: 'AreaChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') - .default, - }, - { - key: 'Avatar', - getComponent: () => require('@coinbase/cds-mobile/media/__stories__/Avatar.stories').default, - }, - { - key: 'AvatarButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/AvatarButton.stories').default, - }, - { - key: 'Axis', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, - }, - { - key: 'Banner', - getComponent: () => require('@coinbase/cds-mobile/banner/__stories__/Banner.stories').default, - }, - { - key: 'BannerActions', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerActions.stories').default, - }, - { - key: 'BannerLayout', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerLayout.stories').default, - }, - { - key: 'BarChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, - }, - { - key: 'Box', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Box.stories').default, - }, - { - key: 'BrowserBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBar.stories').default, - }, - { - key: 'BrowserBarSearchInput', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBarSearchInput.stories').default, - }, - { - key: 'Button', - getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/Button.stories').default, - }, - { - key: 'ButtonGroup', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, - }, - { - key: 'Calendar', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/Calendar.stories').default, - }, - { - key: 'Card', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, - }, - { - key: 'Carousel', - getComponent: () => - require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, - }, - { - key: 'CarouselMedia', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, - }, - { - key: 'CartesianChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') - .default, - }, - { - key: 'ChartAccessibility', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') - .default, - }, - { - key: 'ChartTransitions', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') - .default, - }, - { - key: 'Checkbox', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/Checkbox.stories').default, - }, - { - key: 'CheckboxCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/CheckboxCell.stories').default, - }, - { - key: 'Chip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/Chip.stories').default, - }, - { - key: 'Coachmark', - getComponent: () => - require('@coinbase/cds-mobile/coachmark/__stories__/Coachmark.stories').default, - }, - { - key: 'Collapsible', - getComponent: () => - require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, - }, - { - key: 'Combobox', - getComponent: () => - require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, - }, - { - key: 'ComponentConfigProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, - }, - { - key: 'ComponentConfigProviderCustom', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') - .default, - }, - { - key: 'ContainedAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContainedAssetCard.stories').default, - }, - { - key: 'ContentCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContentCard.stories').default, - }, - { - key: 'ContentCell', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCell.stories').default, - }, - { - key: 'ContentCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCellFallback.stories').default, - }, - { - key: 'ControlGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, - }, - { - key: 'DataCard', - getComponent: () => - require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, - }, - { - key: 'DateInput', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, - }, - { - key: 'DatePicker', - getComponent: () => - require('@coinbase/cds-mobile/dates/__stories__/DatePicker.stories').default, - }, - { - key: 'Divider', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Divider.stories').default, - }, - { - key: 'Dot', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/Dot.stories').default, - }, - { - key: 'DotMisc', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/DotMisc.stories').default, - }, - { - key: 'DrawerBottom', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerBottom.stories').default, - }, - { - key: 'DrawerFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerFallback.stories').default, - }, - { - key: 'DrawerLeft', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerLeft.stories').default, - }, - { - key: 'DrawerMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerMisc.stories').default, - }, - { - key: 'DrawerReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerReduceMotion.stories').default, - }, - { - key: 'DrawerRight', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerRight.stories').default, - }, - { - key: 'DrawerScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerScrollable.stories').default, - }, - { - key: 'DrawerTop', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerTop.stories').default, - }, - { - key: 'Fallback', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Fallback.stories').default, - }, - { - key: 'FloatingAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/FloatingAssetCard.stories').default, - }, - { - key: 'Frontier', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, - }, - { - key: 'Group', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, - }, - { - key: 'HeroSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/HeroSquare.stories').default, - }, - { - key: 'HintMotion', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/HintMotion.stories').default, - }, - { - key: 'IconButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconButton.stories').default, - }, - { - key: 'IconCounterButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconCounterButton.stories').default, - }, - { - key: 'InputChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/InputChip.stories').default, - }, - { - key: 'InputIcon', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIcon.stories').default, - }, - { - key: 'InputIconButton', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIconButton.stories').default, - }, - { - key: 'InputStack', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, - }, - { - key: 'Legend', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, - }, - { - key: 'LinearGradient', - getComponent: () => - require('@coinbase/cds-mobile/gradients/__stories__/LinearGradient.stories').default, - }, - { - key: 'LineChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') - .default, - }, - { - key: 'Link', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Link.stories').default, - }, - { - key: 'ListCell', - getComponent: () => require('@coinbase/cds-mobile/cells/__stories__/ListCell.stories').default, - }, - { - key: 'ListCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ListCellFallback.stories').default, - }, - { - key: 'Logo', - getComponent: () => require('@coinbase/cds-mobile/icons/__stories__/Logo.stories').default, - }, - { - key: 'Lottie', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/Lottie.stories').default, - }, - { - key: 'LottieStatusAnimation', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default, - }, - { - key: 'MediaCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default, - }, - { - key: 'MediaChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default, - }, - { - key: 'MessagingCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, - }, - { - key: 'ModalBackButton', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBackButton.stories').default, - }, - { - key: 'ModalBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBasic.stories').default, - }, - { - key: 'ModalCustomPadding', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalCustomPadding.stories').default, - }, - { - key: 'ModalLong', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalLong.stories').default, - }, - { - key: 'ModalPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalPortal.stories').default, - }, - { - key: 'MultiContentModule', - getComponent: () => - require('@coinbase/cds-mobile/multi-content-module/__stories__/MultiContentModule.stories') - .default, - }, - { - key: 'NavBarIconButton', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavBarIconButton.stories').default, - }, - { - key: 'NavigationSubtitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationSubtitle.stories').default, - }, - { - key: 'NavigationTitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitle.stories').default, - }, - { - key: 'NavigationTitleSelect', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitleSelect.stories').default, - }, - { - key: 'NudgeCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/NudgeCard.stories').default, - }, - { - key: 'Numpad', - getComponent: () => require('@coinbase/cds-mobile/numpad/__stories__/Numpad.stories').default, - }, - { - key: 'Overlay', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/Overlay.stories').default, - }, - { - key: 'PageFooter', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageFooter.stories').default, - }, - { - key: 'PageFooterInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageFooterInPage.stories').default, - }, - { - key: 'PageHeader', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageHeader.stories').default, - }, - { - key: 'PageHeaderInErrorEmptyState', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInErrorEmptyState.stories').default, - }, - { - key: 'PageHeaderInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInPage.stories').default, - }, - { - key: 'Palette', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Palette.stories').default, - }, - { - key: 'PatternDisclosureHighFrictionBenefit', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionBenefit.stories') - .default, - }, - { - key: 'PatternDisclosureHighFrictionRisk', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionRisk.stories') - .default, - }, - { - key: 'PatternDisclosureLowFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureLowFriction.stories') - .default, - }, - { - key: 'PatternDisclosureMedFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureMedFriction.stories') - .default, - }, - { - key: 'PatternError', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, - }, - { - key: 'PercentageBarChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/PercentageBarChart.stories') - .default, - }, - { - key: 'PeriodSelector', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') - .default, - }, - { - key: 'Pictogram', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, - }, - { - key: 'Pressable', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/Pressable.stories').default, - }, - { - key: 'PressableOpacity', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PressableOpacity.stories').default, - }, - { - key: 'ProgressBar', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressBar.stories').default, - }, - { - key: 'ProgressCircle', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressCircle.stories').default, - }, - { - key: 'RadioCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioCell.stories').default, - }, - { - key: 'RadioGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioGroup.stories').default, - }, - { - key: 'ReferenceLine', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') - .default, - }, - { - key: 'RemoteImage', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImage.stories').default, - }, - { - key: 'RemoteImageGroup', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImageGroup.stories').default, - }, - { - key: 'RollingNumber', - getComponent: () => - require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, - }, - { - key: 'Scrubber', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') - .default, - }, - { - key: 'SearchInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SearchInput.stories').default, - }, - { - key: 'SectionHeader', - getComponent: () => - require('@coinbase/cds-mobile/section-header/__stories__/SectionHeader.stories').default, - }, - { - key: 'SegmentedTabs', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/SegmentedTabs.stories').default, - }, - { - key: 'Select', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Select.stories').default, - }, - { - key: 'SelectChip', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, - }, - { - key: 'SelectOption', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SelectOption.stories').default, - }, - { - key: 'SlideButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/SlideButton.stories').default, - }, - { - key: 'Spacer', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Spacer.stories').default, - }, - { - key: 'Sparkline', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, - }, - { - key: 'SparklineGradient', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') - .default, - }, - { - key: 'SparklineInteractive', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') - .default, - }, - { - key: 'SparklineInteractiveHeader', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') - .default, - }, - { - key: 'Spectrum', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Spectrum.stories').default, - }, - { - key: 'Spinner', - getComponent: () => require('@coinbase/cds-mobile/loaders/__stories__/Spinner.stories').default, - }, - { - key: 'SpotIcon', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotIcon.stories').default, - }, - { - key: 'SpotRectangle', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotRectangle.stories').default, - }, - { - key: 'SpotSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotSquare.stories').default, - }, - { - key: 'StepperHorizontal', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperHorizontal.stories').default, - }, - { - key: 'StepperVertical', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperVertical.stories').default, - }, - { - key: 'StickyFooter', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooter.stories').default, - }, - { - key: 'StickyFooterWithTray', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooterWithTray.stories') - .default, - }, - { - key: 'Switch', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Switch.stories').default, - }, - { - key: 'TabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/TabbedChips.stories').default, - }, - { - key: 'TabIndicator', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabIndicator.stories').default, - }, - { - key: 'TabLabel', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/TabLabel.stories').default, - }, - { - key: 'TabNavigation', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabNavigation.stories').default, - }, - { - key: 'Tabs', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, - }, - { - key: 'Tag', - getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, - }, - { - key: 'Text', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Text.stories').default, - }, - { - key: 'TextBody', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextBody.stories').default, - }, - { - key: 'TextCaption', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCaption.stories').default, - }, - { - key: 'TextCore', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCore.stories').default, - }, - { - key: 'TextDisplay1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay1.stories').default, - }, - { - key: 'TextDisplay2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay2.stories').default, - }, - { - key: 'TextDisplay3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay3.stories').default, - }, - { - key: 'TextHeadline', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextHeadline.stories').default, - }, - { - key: 'TextInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/TextInput.stories').default, - }, - { - key: 'TextLabel1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel1.stories').default, - }, - { - key: 'TextLabel2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel2.stories').default, - }, - { - key: 'TextLegal', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLegal.stories').default, - }, - { - key: 'TextTitle1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle1.stories').default, - }, - { - key: 'TextTitle2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle2.stories').default, - }, - { - key: 'TextTitle3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle3.stories').default, - }, - { - key: 'TextTitle4', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle4.stories').default, - }, - { - key: 'ThemeProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ThemeProvider.stories').default, - }, - { - key: 'Toast', - getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/Toast.stories').default, - }, - { - key: 'TooltipV2', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TooltipV2.stories').default, - }, - { - key: 'TopNavBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/TopNavBar.stories').default, - }, - { - key: 'Tour', - getComponent: () => require('@coinbase/cds-mobile/tour/__stories__/Tour.stories').default, - }, - { - key: 'TrayAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayAction.stories').default, - }, - { - key: 'TrayBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayBasic.stories').default, - }, - { - key: 'TrayFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFallback.stories').default, - }, - { - key: 'TrayFeedCard', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, - }, - { - key: 'TrayInformational', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayInformational.stories').default, - }, - { - key: 'TrayMessaging', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMessaging.stories').default, - }, - { - key: 'TrayMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMisc.stories').default, - }, - { - key: 'TrayNavigation', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayNavigation.stories').default, - }, - { - key: 'TrayPromotional', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayPromotional.stories').default, - }, - { - key: 'TrayRedesign', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayRedesign.stories').default, - }, - { - key: 'TrayReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayReduceMotion.stories').default, - }, - { - key: 'TrayScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayScrollable.stories').default, - }, - { - key: 'TrayTall', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayTall.stories').default, - }, - { - key: 'TrayWithTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayWithTitle.stories').default, - }, - { - key: 'UpsellCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/UpsellCard.stories').default, - }, -]; diff --git a/apps/mobile-app/scripts/utils/setEnvVars.mjs b/apps/mobile-app/scripts/utils/setEnvVars.mjs deleted file mode 100644 index c2b501d05b..0000000000 --- a/apps/mobile-app/scripts/utils/setEnvVars.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './getBuildInfo.mjs'; - -export function setEnvVars() { - const { debug = false, newArchEnabled = false, jsEngine, profile, platform } = argv; - const { ios, android, outputDirectory } = getBuildInfo(); - - /** - * Environment variables for Expo CLI builds - */ - $.prefix += ` - export RCT_NO_LAUNCH_PACKAGER=1; - export APP_PROFILE=${profile}; - export APP_PLATFORM=${platform}; - export APP_JS_ENGINE=${jsEngine}; - export APP_IOS_BUNDLE_IDENTIFIER=${ios.bundleIdentifier}; - export APP_ANDROID_PACKAGE_IDENTIFIER=${android.packageIdentifier}; - export EXPO_NO_TELEMETRY=1; - export EXPO_USE_CUSTOM_INSPECTOR_PROXY=1; - export BUILD_ARTIFACTS_DIR=${outputDirectory}; - export EXPO_NO_REDIRECT_PAGE=1; - export EXPO_USE_UPDATES=1; - `; - - if (debug) { - $.prefix += ` - export DEBUG=*; - export APP_DEBUG=1; - export BUILD_SKIP_CLEANUP=1; - export EXPO_PROFILE=1; - `; - } - - if (newArchEnabled) { - $.prefix += `export APP_NEW_ARCH_ENABLED=1;`; - } -} diff --git a/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs b/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs deleted file mode 100644 index 29a6dce323..0000000000 --- a/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { getAffectedRoutes } from './getAffectedRoutes.mjs'; - -const { commonChanged, affectedRouteKeys } = await getAffectedRoutes(); - -// If we're not on the master branch and nothing relevant has changed, we don't need to run detox -if (!commonChanged && !affectedRouteKeys.length) process.exit(1); - -process.exit(0); diff --git a/apps/mobile-app/scripts/validate.mjs b/apps/mobile-app/scripts/validate.mjs deleted file mode 100644 index 09da5d919c..0000000000 --- a/apps/mobile-app/scripts/validate.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { $, log } from 'zx'; // https://github.com/google/zx - -$.verbose = true; - -/** - * Fail if any installed packages are outdated. - * https://docs.expo.dev/workflow/expo-cli/#environment-variables:~:text=on%20your%20machine.-,CI,-boolean - */ -const { stdout, stderr } = await $`expo install --check`; -if (stdout) { - log({ kind: 'stdout', data: stdout }); -} - -if (stderr) { - log({ kind: 'stderr', data: stderr }); -} diff --git a/apps/mobile-app/src/index.ts b/apps/mobile-app/src/index.ts deleted file mode 100644 index 2bd5dc541f..0000000000 --- a/apps/mobile-app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Empty file to please tsconfig `include` `src` config diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 9549d3063d..2c48226e3c 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -24,8 +24,8 @@ const bundleStatsFilename = path.resolve( ); const addons = [ // '@chromatic-com/storybook', - '@storybook/addon-storysource', '@storybook-community/storybook-dark-mode', + '@storybook/addon-docs', ...(!isPercyBuild ? ['@storybook/addon-a11y', '@storybook/addon-vitest'] : []), ]; @@ -46,10 +46,7 @@ const config: StorybookConfig = { options: {}, }, addons, - stories: [ - '../../../packages/web/**/*.stories.@(tsx|mdx)', - '../../../packages/web-visualization/**/*.stories.@(tsx|mdx)', - ], + stories: ['../../../packages/web/**/*.stories.@(tsx|mdx)'], staticDirs: [ { from: path.resolve(MONOREPO_ROOT, 'packages/icons/src'), @@ -91,10 +88,6 @@ const config: StorybookConfig = { '@coinbase/cds-lottie-files': path.resolve(MONOREPO_ROOT, 'packages/lottie-files/src'), '@coinbase/cds-utils': path.resolve(MONOREPO_ROOT, 'packages/utils/src'), '@coinbase/cds-web': path.resolve(MONOREPO_ROOT, 'packages/web/src'), - '@coinbase/cds-web-visualization': path.resolve( - MONOREPO_ROOT, - 'packages/web-visualization/src', - ), }, }, }); diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index 7090edef8b..618f80314f 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -54,7 +54,7 @@ const preview: Preview = { decorators: [StoryContainer], parameters: { layout: 'fullscreen', - backgrounds: { disable: true }, + backgrounds: { disabled: true }, globalStyles: `${globalStyles} ${defaultFontStyles}`, controls: { matchers: { diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 561f5f3ab3..dc1ae3331c 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -11,36 +11,35 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { "@linaria/babel-preset": "^3.0.0-beta.22", "@linaria/core": "^3.0.0-beta.22", "@linaria/rollup": "^3.0.0-beta.22", "@percy/cli": "^1.31.1", - "@percy/storybook": "^9.0.0", + "@percy/storybook": "^9.1.0", "@shopify/storybook-a11y-test": "^1.2.1", "@storybook-community/storybook-dark-mode": "^6.0.0", "@storybook/addon-a11y": "^9.1.19", - "@storybook/addon-storysource": "^8.6.14", + "@storybook/addon-docs": "9.1.17", "@storybook/addon-vitest": "^9.1.2", "@storybook/jest": "^0.2.3", - "@storybook/react-vite": "^9.1.2", + "@storybook/react-vite": "9.1.17", "@storybook/testing-library": "^0.2.2", "@types/diff": "^5.0.9", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "diff": "^5.1.0", "playwright": "^1.58.2", "rollup-plugin-visualizer": "^6.0.3", - "storybook": "^9.1.2", + "storybook": "9.1.17", "typescript": "~5.9.2", - "vite": "^7.1.2", + "vite": "^7.3.1", "vitest": "^4.0.18" } } diff --git a/apps/storybook/project.json b/apps/storybook/project.json index 0706bfb3da..84bd2bfebf 100644 --- a/apps/storybook/project.json +++ b/apps/storybook/project.json @@ -22,7 +22,6 @@ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*", "!{projectRoot}/scripts/**" ], "outputs": [ @@ -40,7 +39,6 @@ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*", "!{projectRoot}/scripts/**" ], "outputs": [ @@ -70,8 +68,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist" @@ -87,8 +84,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist", @@ -130,8 +126,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist" diff --git a/apps/storybook/scripts/shouldRunVisreg.mjs b/apps/storybook/scripts/shouldRunVisreg.mjs index b8185fef25..6183656dae 100644 --- a/apps/storybook/scripts/shouldRunVisreg.mjs +++ b/apps/storybook/scripts/shouldRunVisreg.mjs @@ -4,7 +4,6 @@ const RELEVANT_ROOTS = [ 'apps/storybook', 'packages/common', 'packages/web', - 'packages/web-visualization', 'packages/icons', 'packages/illustrations', ]; diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index f1dd49fa55..24977feae5 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -26,9 +26,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/apps/vite-app/index.html b/apps/vite-app/index.html index c19f9c4a27..a14497510e 100644 --- a/apps/vite-app/index.html +++ b/apps/vite-app/index.html @@ -2,7 +2,6 @@ - CDS Vite App diff --git a/apps/vite-app/package.json b/apps/vite-app/package.json index c8f9a300e1..888ade3742 100644 --- a/apps/vite-app/package.json +++ b/apps/vite-app/package.json @@ -13,17 +13,17 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@coinbase/cds-migrator": "workspace:^", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "typescript": "~5.9.2", - "vite": "^7.1.2" + "vite": "^7.3.1" }, "packageManager": "yarn@4.7.0" } diff --git a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx index 4d976dbebb..b33bda12f7 100644 --- a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx +++ b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx @@ -7,7 +7,6 @@ export const ETHStakingCard = () => { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -18,6 +17,7 @@ export const ETHStakingCard = () => { } + style={{ backgroundColor: 'rgb(var(--purple70))' }} title={ Up to 3.29% APR on ETHs diff --git a/apps/vite-app/tsconfig.app.json b/apps/vite-app/tsconfig.app.json index e61b84d78c..bb78b43086 100644 --- a/apps/vite-app/tsconfig.app.json +++ b/apps/vite-app/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.project.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, "noEmit": true, "noUncheckedSideEffectImports": true @@ -22,9 +22,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/apps/vite-app/tsconfig.node.json b/apps/vite-app/tsconfig.node.json index 09d8b6a68f..8381935dd9 100644 --- a/apps/vite-app/tsconfig.node.json +++ b/apps/vite-app/tsconfig.node.json @@ -21,9 +21,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/eslint.config.mjs b/eslint.config.mjs index aa1085e4b6..1f3e98e7a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,24 +32,35 @@ const ignores = [ '**/esm/**', '**/lib/**', '**/templates/**', + '**/__testfixtures__/**', '**/.next/**', // These files use assert { type: 'json' } syntax that breaks eslint and must be fully ignored '**/getAffectedRoutes.mjs', '**/getBuildInfo.mjs', - 'apps/mobile-app/prebuilds', // within their NX project, these files are not included by the Typescript config // when linting with TS types (e.g. internal/safely-spread-props) this will raise an error 'packages/web/optimize-css.ts', 'packages/icons/scripts/*.ts', 'packages/illustrations/scripts/*.ts', - 'packages/ui-mobile-playground/scripts/*.ts', 'libs/docusaurus-plugin-docgen/module-declarations.d.ts', ]; +// TODO (CDS-1412): Fix these react-hooks rule violations and re-enable them +const disabledNewReactHooksRules = { + 'react-hooks/immutability': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/set-state-in-render': 'off', + 'react-hooks/static-components': 'off', + 'react-hooks/preserve-manual-memoization': 'off', +}; + // These rules apply to all files const sharedRules = { 'internal/no-object-rest-spread-in-worklet': 'error', 'internal/deprecated-jsdoc-has-removal-version': 'error', + 'internal/spread-props-last': 'warn', 'import/default': 'off', 'import/extensions': 'off', 'import/named': 'off', @@ -68,6 +79,16 @@ const sharedRules = { message: 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, ], patterns: [ { @@ -137,6 +158,96 @@ const sharedRules = { ], 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', + ...disabledNewReactHooksRules, +}; + +// React 19 introduced new APIs that do not exist in React 18. +// CDS must remain compatible with React 18 consumers, so we restrict these imports +// in all publishable packages. The `no-restricted-imports` rule in this object +// is a superset of the one in `sharedRules` to avoid flat-config override issues. +const react19CompatibilityRules = { + 'no-restricted-imports': [ + 'error', + { + paths: [ + // Existing restrictions (duplicated because flat config replaces, not merges) + { + name: '@linaria/core', + importNames: ['cx'], + message: + 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', + }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + // React 19-only runtime APIs + { + name: 'react', + importNames: ['cache', 'captureOwnerStack', 'use', 'useActionState', 'useOptimistic'], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React 19-only types (would break .d.ts output for React 18 consumers) + { + name: 'react', + importNames: ['ActionDispatch', 'AnyActionArg', 'AwaitedReactNode', 'Usable'], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only runtime APIs + { + name: 'react-dom', + importNames: [ + 'preconnect', + 'prefetchDNS', + 'preinit', + 'preinitModule', + 'preload', + 'preloadModule', + 'requestFormReset', + 'useFormState', + 'useFormStatus', + ], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only types + { + name: 'react-dom', + importNames: [ + 'FormStatus', + 'FormStatusNotPending', + 'FormStatusPending', + 'PreconnectOptions', + 'PreinitAs', + 'PreinitModuleAs', + 'PreinitModuleOptions', + 'PreinitOptions', + 'PreloadAs', + 'PreloadModuleAs', + 'PreloadModuleOptions', + 'PreloadOptions', + ], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + ], + patterns: [ + { + group: ['*/booleanStyles', '*/responsive/*'], + message: + 'Do not import these styles directly, as it will cause non-deterministic CSS generation. Use the `getStyles` function from @coinbase/cds-web/styles/styleProps.ts or the component StyleProps API instead.', + }, + ], + }, + ], }; // These rules only apply to TS/TSX files in packages/**, and do not apply to stories or tests @@ -176,6 +287,7 @@ const typescriptRules = { // These rules only apply to test files const testRules = { + 'internal/spread-props-last': 'off', 'jest/no-mocks-import': 'off', 'testing-library/await-async-events': 'off', 'testing-library/await-async-queries': 'off', @@ -209,7 +321,7 @@ const sharedExtends = [ eslintJs.configs.recommended, eslintImport.flatConfigs.recommended, eslintReact.configs.flat.recommended, - eslintReactHooks.configs['recommended-latest'], + eslintReactHooks.configs.flat['recommended-latest'], eslintReactPerf.configs.flat.recommended, eslintJsxA11y.flatConfigs.recommended, ]; @@ -274,7 +386,6 @@ export default tseslint.config( files: ['packages/**/*.{ts,tsx}'], ignores: [ 'packages/illustrations/src/__generated__/**', - 'packages/ui-mobile-playground/**', 'packages/**/__stories__/**', 'packages/**/__tests__/**', 'packages/**/__mocks__/**', @@ -309,6 +420,23 @@ export default tseslint.config( ...typescriptRules, }, }, + // Restrict React 19-only APIs in publishable packages to maintain React 18 compatibility + { + files: [ + 'packages/web/**/*.{ts,tsx}', + 'packages/common/**/*.{ts,tsx}', + 'packages/mobile/**/*.{ts,tsx}', + ], + rules: { + ...react19CompatibilityRules, + }, + }, + { + files: ['**/*.stories.{js,jsx,ts,tsx}', '**/__stories__/**'], + rules: { + 'internal/spread-props-last': 'off', + }, + }, // Rules specific to mobile story files { files: ['packages/mobile/**/*.stories.tsx'], @@ -318,13 +446,16 @@ export default tseslint.config( { files: ['**/*.figma.tsx'], extends: [internalPlugin.configs.figmaConnectRules], + rules: { + 'internal/spread-props-last': 'off', + }, }, { files: ['**/*.mdx'], processor: internalPlugin.processors.mdx, }, { - files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/setup.js'], + files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/jest/**/*.js'], settings: sharedSettings, languageOptions: { globals: { @@ -348,4 +479,17 @@ export default tseslint.config( ...testRules, }, }, + { + files: ['packages/migrator/**/*.test.{ts,tsx}'], + rules: { + 'jest/expect-expect': 'off', + }, + }, + { + files: ['packages/migrator/src/transforms/**/*.ts'], + ignores: ['packages/migrator/src/transforms/**/__tests__/**'], + rules: { + 'no-restricted-exports': 'off', + }, + }, ); diff --git a/figma.config.mobile.json b/figma.config.mobile.json index ca11d800b8..29fedf2496 100644 --- a/figma.config.mobile.json +++ b/figma.config.mobile.json @@ -3,8 +3,7 @@ "parser": "react", "label": "React Native", "include": [ - "packages/mobile/src/**/*.tsx", - "packages/mobile-visualization/src/**/*.tsx" + "packages/mobile/src/**/*.tsx" ], "exclude": [ "**/__tests__/**", diff --git a/figma.config.web.json b/figma.config.web.json index 4a3dbbe934..d18cd6edf9 100644 --- a/figma.config.web.json +++ b/figma.config.web.json @@ -3,8 +3,7 @@ "parser": "react", "label": "React", "include": [ - "packages/web/src/**/*.tsx", - "packages/web-visualization/src/**/*.tsx" + "packages/web/src/**/*.tsx" ], "exclude": [ "**/__tests__/**", diff --git a/jest.preset-mobile.js b/jest.preset-mobile.js index 7e9524b889..b732cfd97e 100644 --- a/jest.preset-mobile.js +++ b/jest.preset-mobile.js @@ -18,7 +18,7 @@ const config = { '\\.(jpg|jpeg|png|gif)$': 'identity-obj-proxy', }, setupFiles: [...reactNativePreset.setupFiles], - setupFilesAfterEnv: ['jest-extended', '@testing-library/jest-native/extend-expect'], + setupFilesAfterEnv: ['jest-extended'], testMatch: ['**/*.test.[jt]s?(x)'], testPathIgnorePatterns: [ '/node_modules/', diff --git a/libs/codegen/README.md b/libs/codegen/README.md index 28c6d441ed..1af1fd8452 100644 --- a/libs/codegen/README.md +++ b/libs/codegen/README.md @@ -1,15 +1,46 @@ # Codegen -We have a number of disparate codegen scripts in our repo. This doc page serves as the jump off doc for understanding their functionality. +A collection of code generation scripts for the CDS monorepo. -# Codegen Scripts in this Package +## Nx Targets -- Adoption Tracker -- Icons -- Illustrations -- Playground route generation -- cds-web/cds-mobile/cds-common token generator scripts +### `yarn nx run codegen:mobile-routes` -## EJS Templates +Scans all mobile story files (`packages/**/mobile/src/**/__stories__/*.stories.tsx`) and generates the route table for `apps/expo-app/src/routes.ts`. -Codegen needs to be run before using CDS or running the storybook. It uses [ejs](https://ejs.co/) templates to generate source code. The ejs templates live in [`templates/`](./templates). The folder structure in `templates/` should mimic the source file structure that the codegen output should be. The only exception is codegen a component. The component's folder can be skipped by having `shouldCreateFolder` option set to `true` in the codegen script. Rather than creating `templates/components/Button/Button.ejs`, `templates/components/Button.ejs` will suffice. +Run this when adding or removing mobile component stories. + +**Source:** `src/playground/prepareRoutes.ts` + +--- + +### `yarn nx run codegen:icon-svg-map` + +Reads every SVG file from `packages/icons/src/svgs/` and generates a static map at `apps/expo-app/src/__generated__/iconSvgMap.ts`. The map keys are `iconName-size-state` (e.g. `account-24-active`) and each value holds the raw SVG string, used by expo-app to render icons directly via `react-native-svg`. + +Run this when icons are added, removed, or updated in `packages/icons`. + +**Source:** `src/icons/generateIconSvgMap.ts` + +--- + +### `yarn nx run codegen:update-packages-generic-bump` + +Syncs the version numbers of `web`, `mobile`, `common`, and `mcp-server` packages to the highest version among them, and inserts a corresponding entry into each out-of-date `CHANGELOG.md`. Used during the release process to keep versions in lockstep. + +**Source:** `src/release/updatePkgsForGenericBump.ts` + +--- + +## Utilities + +Shared helpers used internally by codegen scripts (`src/utils/`): + +- `getSourcePath` — resolves an absolute path from the monorepo root, using `PROJECT_CWD` or `NX_MONOREPO_ROOT` env vars +- `writeFile` — renders an EJS template (or raw string) and writes the output, optionally running Prettier +- `buildTemplates` — batch wrapper around `writeFile` for running multiple template renders +- `writePrettyFile` — writes a file and formats it with Prettier +- `getHeaderCommentForFileType` — returns the appropriate "DO NOT MODIFY" header comment for a given file extension +- `getPrettierParser` — returns the Prettier parser for a given file extension +- `sortAlphabetically` — sorts an array of strings alphabetically +- `logError` — standardized error logging diff --git a/libs/codegen/package.json b/libs/codegen/package.json index e936f26d08..ba16032160 100644 --- a/libs/codegen/package.json +++ b/libs/codegen/package.json @@ -33,20 +33,15 @@ ], "dependencies": { "@coinbase/cds-utils": "workspace:^", - "@k-vyn/coloralgorithm": "^1.0.0", "chalk": "^4.1.2", "ejs": "^3.1.7", - "enquirer": "^2.3.6", "fast-glob": "^3.2.11", - "hygen": "patch:hygen@^6.2.0#./patches/hygen.patch", - "semver": "^7.5.4", - "yargs": "^17.5.1", - "yargs-parser": "^21.0.1" + "semver": "^7.5.4" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/codegen/patches/hygen.patch b/libs/codegen/patches/hygen.patch deleted file mode 100644 index ca3c810f6d..0000000000 --- a/libs/codegen/patches/hygen.patch +++ /dev/null @@ -1,98 +0,0 @@ -diff --git a/dist/engine.js b/dist/engine.js -index a677fad981f78e88b142de9f037b5e1c544523bd..704e11c205dc316e89a1cfdb53d784f331a4e3b4 100644 ---- a/dist/engine.js -+++ b/dist/engine.js -@@ -62,7 +62,7 @@ Options: - if (!action) { - throw new ShowHelpError(`please specify an action for ${generator}.`); - } -- logger.log(`Loaded templates: ${templates.replace(`${cwd}/`, '')}`); -+ - if (!(yield fs_extra_1.default.exists(actionfolder))) { - throw new ShowHelpError(`I can't find action '${action}' for generator '${generator}'. - -diff --git a/dist/ops/add.js b/dist/ops/add.js -index e2c120a440fe363b9b3e4640a7873754afd5d4cf..95f4081c2fb7222ca0ee8f465cfdefd6eef4f3cd 100644 ---- a/dist/ops/add.js -+++ b/dist/ops/add.js -@@ -16,7 +16,7 @@ const path_1 = __importDefault(require("path")); - const fs_extra_1 = __importDefault(require("fs-extra")); - const chalk_1 = require("chalk"); - const result_1 = __importDefault(require("./result")); --const add = (action, args, { logger, cwd, createPrompter }) => __awaiter(void 0, void 0, void 0, function* () { -+const add = (action, args, { logger, cwd, createPrompter, writeFile }) => __awaiter(void 0, void 0, void 0, function* () { - const { attributes: { to, inject, unless_exists, force, from, skip_if }, } = action; - const result = (0, result_1.default)('add', to); - const prompter = createPrompter(); -@@ -48,13 +48,13 @@ const add = (action, args, { logger, cwd, createPrompter }) => __awaiter(void 0, - return result('skipped'); - } - if (from) { -- const from_path = path_1.default.join(args.templates, from); -+ const from_path = path_1.default.join(cwd, from); - const file = fs_extra_1.default.readFileSync(from_path).toString(); - action.body = file; - } - if (!args.dry) { - yield fs_extra_1.default.ensureDir(path_1.default.dirname(absTo)); -- yield fs_extra_1.default.writeFile(absTo, action.body); -+ writeFile ? yield writeFile(absTo, action.body, args) : yield fs_extra_1.default.writeFile(absTo, action.body); - } - const pathToLog = process.env.HYGEN_OUTPUT_ABS_PATH ? absTo : to; - logger.ok(` ${force ? 'FORCED' : 'added'}: ${pathToLog}`); -diff --git a/dist/render.js b/dist/render.js -index 5d7c6e52834117fff8180ec75e4c79a69fcd002e..a0c76d955306e551721ace6827732509eac7d9a4 100644 ---- a/dist/render.js -+++ b/dist/render.js -@@ -60,14 +60,22 @@ const render = (args, config) => __awaiter(void 0, void 0, void 0, function* () - return Object.assign({ file }, (0, front_matter_1.default)(text, { allowUnsafe: true })); - })) - .then(map(({ file, attributes, body }) => { -+ const argsAsObject = {}; -+ for (const [key, value] of Object.entries(args)) { -+ try { -+ argsAsObject[key] = JSON.parse(value); -+ } catch (err) { -+ argsAsObject[key] = value; -+ } -+ } - const renderedAttrs = Object.entries(attributes).reduce((obj, [key, value]) => { -- return Object.assign(Object.assign({}, obj), { [key]: renderTemplate(value, args, config) }); -+ return Object.assign(Object.assign({}, obj), { [key]: renderTemplate(value, argsAsObject, config) }); - }, {}); - debug('Rendering file: %o', file); - return { - file, - attributes: renderedAttrs, -- body: renderTemplate(body, Object.assign(Object.assign({}, args), { attributes: renderedAttrs }), config), -+ body: renderTemplate(body, Object.assign(Object.assign({}, argsAsObject), { attributes: renderedAttrs }), config), - }; - })); - }); -diff --git a/dist/types.d.ts b/dist/types.d.ts -index c2d1f2c33b6a65d43121a0488e46b90b7871e50e..2ab5c977206bb97ee657db64c74691eea1a68945 100644 ---- a/dist/types.d.ts -+++ b/dist/types.d.ts -@@ -14,7 +14,11 @@ export interface RenderedAction { - attributes: any; - body: string; - } --export interface RunnerConfig { -+export interface RunnerConfig { - exec?: (sh: string, body: string) => void; - templates?: string; - cwd?: string; -@@ -23,7 +27,9 @@ export interface RunnerConfig { - helpers?: any; - localsDefaults?: any; - createPrompter?: () => Prompter; -+ writeFile?: (path: string, contents: string, args: Args) => Promise; - } -+ - export interface ResolverIO { - exists: (arg0: string) => Promise; - load: (arg0: string) => Promise>; diff --git a/libs/codegen/project.json b/libs/codegen/project.json index 7efd5cd7c8..eab5055529 100644 --- a/libs/codegen/project.json +++ b/libs/codegen/project.json @@ -28,9 +28,9 @@ } }, "update-packages-generic-bump": { - "command": "node ./src/release/updatePkgsForGenericBump.mjs", + "command": "node {projectRoot}/src/release/updatePkgsForGenericBump.mjs", "options": { - "cwd": "libs/codegen" + "cwd": "{workspaceRoot}" } }, "mobile-routes": { @@ -38,6 +38,12 @@ "options": { "cwd": "libs/codegen" } + }, + "icon-svg-map": { + "command": "tsx ./src/icons/generateIconSvgMap.ts", + "options": { + "cwd": "libs/codegen" + } } } } diff --git a/libs/codegen/src/bin.ts b/libs/codegen/src/bin.ts deleted file mode 100644 index 2857074da9..0000000000 --- a/libs/codegen/src/bin.ts +++ /dev/null @@ -1,18 +0,0 @@ -import parser from 'yargs-parser'; - -import { codegen } from './codegen'; - -// The first two items in process.argv is path to process and path of this file, which are not needed. -const args = process.argv.slice(2); -const { _: template, ...cliArgs } = parser(args); - -/** - * Execute hygen via terminal with optional args. - * Custom config for this CDS version of hygen can be found in codegen/config.ts file. - * @example - * ```sh - * yarn codegen cli - * ``` - * @link http://www.hygen.io/docs/generators#interactive-prompt - */ -void codegen(template.join('/'), cliArgs); diff --git a/libs/codegen/src/codegen.ts b/libs/codegen/src/codegen.ts deleted file mode 100644 index 6735f788fd..0000000000 --- a/libs/codegen/src/codegen.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { runner } from 'hygen/dist/index'; - -import { config } from './config'; - -function parseArgs(template: string, opts: Record) { - const [generator, action, subaction] = template.split('/'); - const formattedOpts = Object.entries(opts).reduce((prev, [key, value]) => { - /** Hygen accepts args via cli which requires the value to be converted to a string */ - const sanitizedValue = typeof value === 'object' ? JSON.stringify(value) : value; - return [...prev, `--${key}=${sanitizedValue}`]; - }, [] as string[]); - - return [generator, subaction ? `${action}:${subaction}` : action, ...formattedOpts]; -} - -export async function codegen(template: string, opts?: T) { - await runner(parseArgs(template, opts ?? {}), config).then(({ failure }) => { - if (failure) { - process.exit(1); - } - }); -} diff --git a/libs/codegen/src/config.ts b/libs/codegen/src/config.ts deleted file mode 100644 index 0245d05c56..0000000000 --- a/libs/codegen/src/config.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { kebabCase, pascalCase, toCssVar, toCssVarFn } from '@coinbase/cds-utils'; -import enquirer from 'enquirer'; -import type { Options } from 'execa'; -import { command } from 'execa'; -import HygenLogger from 'hygen/dist/logger'; -import type { RunnerConfig } from 'hygen/dist/types'; -import lodash from 'lodash'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { formatTemplateType } from './utils/formatTemplateType'; -import { getHeaderCommentForFileType } from './utils/getHeaderCommentForFileType'; -import { getPrettierParser } from './utils/getPrettierParser'; -import { getSourcePath } from './utils/getSourcePath'; -import { writePrettyFile } from './utils/writePrettyFile'; - -const root = process.env.PROJECT_CWD ?? process.env.NX_MONOREPO_ROOT; -const templates = getSourcePath('libs/codegen/src/templates'); - -const { camelCase } = lodash; - -const Logger = (HygenLogger as any).default as HygenLogger; - -type WriteFileConfig = { - defaultExport?: boolean; - commonJS?: boolean; - disableAsConst?: boolean; - disableStringify?: boolean; - disablePrettier?: boolean; - sort?: boolean; -}; - -/** Custom write file fn for hygen to format contents with prettier and add header comment. */ -async function writeFile(dest: string, contents: string, { config }: { config?: string }) { - const { disablePrettier } = (config ? JSON.parse(config) : {}) as WriteFileConfig; - try { - const ext = path.extname(dest); - const newContents = getHeaderCommentForFileType(ext) + contents; - const dirForFile = path.dirname(dest); - // If directory doesn't already exist, create it. - fs.mkdirSync(dirForFile, { recursive: true }); - if (disablePrettier) { - await fs.promises.writeFile(dest, newContents, { encoding: 'utf8', flag: 'w' }); - } else { - await writePrettyFile({ - outFile: dest, - contents: newContents, - logInfo: false, - parser: getPrettierParser(ext), - }); - } - } catch (error) { - if (error instanceof Error) { - console.error(error); - throw new Error(`Couldn't generate ${dest}.`); - } else { - throw error; - } - } -} - -/** Accessible in templates directly. - * @example - * ```sh - * --- - * to: packages/common/src/internal/data/iconData.ts - * force: true - * --- - * <%- include(partial.objectMap, { data: iconData }); %> - * ``` - */ -const localsDefaults = { - partial: { - objectMap: path.join(templates, `partials/objectMap.ejs.t`), - typescript: path.join(templates, `partials/typescript.ejs.t`), - }, -}; - -/** - * - * @param prefix - the prefix you want the css variable to use. This will be the broader cateogry a token belongs to like sizing or palette. - * @param alias - the alias for this token. i.e. rounded or xs - * @returns string - the final css variable - */ -function getCssAlias(prefix: string, alias: string) { - return `${kebabCase(prefix)}-${alias}`; -} - -function toCssVarSetter(prefix: string, alias: string) { - return toCssVar(getCssAlias(prefix, alias)); -} - -function toCssVarGetter(prefix: string, alias: string) { - return toCssVarFn(getCssAlias(prefix, alias)); -} - -/** - * Add any helpers you want to have accessible in hygen templates i.e `h.camelCase` - * @link http://www.hygen.io/docs/templates - * @example - * ```sh - * --- - * to: apps/website/docs/components/<%- h.pascalCase(name) %>.tsx - * force: true - * --- - * ``` - */ -const helpers = { - camelCase, - kebabCase, - format: formatTemplateType, - pascalCase, - toCssVarGetter, - toCssVarSetter, -}; - -export const config: RunnerConfig = { - cwd: root, - templates, - // @ts-expect-error HygenLogger types are invalid - logger: new Logger(console.log.bind(console)), - debug: !!process.env.DEBUG, - exec: async (action: string, body: string) => { - const opts: Options = { input: body && body.length > 0 ? body : '' }; - return command(action, { ...opts, shell: true }); - }, - // @ts-expect-error Enquirer types are narrower then hygen prompter types - createPrompter: () => enquirer, - localsDefaults, - helpers, - writeFile, -}; diff --git a/libs/codegen/src/icons/generateIconSvgMap.ts b/libs/codegen/src/icons/generateIconSvgMap.ts new file mode 100644 index 0000000000..39899b444e --- /dev/null +++ b/libs/codegen/src/icons/generateIconSvgMap.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { getSourcePath } from '../utils/getSourcePath'; +import { writeFile } from '../utils/writeFile'; + +const SVG_SOURCE_DIR = 'packages/icons/src/svgs'; +const OUTPUT_PATH = 'apps/expo-app/src/__generated__/iconSvgMap.ts'; + +const HEADER = `/** + * DO NOT MODIFY + * This file is generated by libs/codegen/src/icons/generateIconSvgMap.ts + * + * Why this exists: + * - Provides a static map of icon names to their SVG content for rendering Icons directly with react-native-svg components + * + * What this provides: + * - A static map of iconName-12|16|24|32-active|inactive → { content: "svg-string" } + * + * Usage: + * - Access SVG string content via: svgMap['icon-name-12-active'].content + */`; + +export function generateIconSvgMap(): void { + console.log('Generating React Native SVG map...'); + + const svgDir = getSourcePath(SVG_SOURCE_DIR); + const svgFiles = fs + .readdirSync(svgDir) + .filter((file) => file.endsWith('.svg')) + .sort((a, b) => a.localeCompare(b)); + + if (svgFiles.length === 0) { + console.log('No SVG files found, skipping SVG map generation'); + return; + } + + const mapEntries = svgFiles.map((file) => { + const base = file.replace(/\.svg$/, ''); + const svgContent = fs.readFileSync(path.join(svgDir, file), 'utf8'); + const escaped = svgContent + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + return ` '${base}': { content: "${escaped}" },`; + }); + + const content = `${HEADER} + +export const svgMap: Record = { +${mapEntries.join('\n')} +} as const; + +export type SvgMapEntry = { content: string }; +export type SvgMap = Record; +export type SvgKey = keyof typeof svgMap; + +export default svgMap; +`; + + void writeFile({ + data: content, + dest: OUTPUT_PATH, + config: { disablePrettier: true }, + }).then(() => { + console.log(`Generated React Native SVG map with ${svgFiles.length} entries -> ${OUTPUT_PATH}`); + }); +} + +generateIconSvgMap(); diff --git a/libs/codegen/src/playground/prepareRoutes.ts b/libs/codegen/src/playground/prepareRoutes.ts index d4eb5cbb47..64cd5e87bc 100644 --- a/libs/codegen/src/playground/prepareRoutes.ts +++ b/libs/codegen/src/playground/prepareRoutes.ts @@ -5,20 +5,10 @@ import { getSourcePath } from '../utils/getSourcePath'; import { writeFile } from '../utils/writeFile'; /** + * Computes the package import path for a given story file path. * - * We need to compute the relative path given a filePath. It should - * be relative to the 'packages/mobile/examples' folder. - * - * Previously, we hardcoded the relative path in - * mobileRoutes.ejs. But with more mobile packages, stories can be live in - * other packages other than packages/mobile. Thus, adding '../' is - * insufficient. You can't reach mobile-visualization by going 1 folder up from - * 'packages/mobile/examples'. You need to go 2 folders up, so you need to - * appending '../../' to the filePath. This function determines its folder and - * computes the correct relative path. - * - * @param filePath The path of the file - * @returns The relative path. It is relative to 'packages/mobile/examples' + * @param filePath The path of the file relative to packages/ + * @returns The package import path (e.g. `@coinbase/cds-mobile/...`) */ function getRelativePath(filePath: string) { const relativePath = filePath.replace('.tsx', ''); @@ -30,18 +20,12 @@ async function getRoutes() { try { const rootDir = getSourcePath('packages'); - // Our stories may come from other packages not within mobile, so - // we are adding a new regular expression to capture stories that are - // in other mobile packages - const files = await glob( - ['**/(mobile|mobile-visualization)/src/**/__stories__/*.stories.(ts|tsx|js|jsx)'], - { - ignore: ['__tests__/*'], - onlyFiles: true, - cwd: rootDir, - absolute: false, - }, - ); + const files = await glob(['**/mobile/src/**/__stories__/*.stories.(ts|tsx|js|jsx)'], { + ignore: ['__tests__/*'], + onlyFiles: true, + cwd: rootDir, + absolute: false, + }); const processedFiles = files .map((file) => { @@ -69,48 +53,16 @@ export async function prepare() { try { const routes = await getRoutes(); - const hotReloadRoutes = routes.map((route) => ({ - name: route.name, - path: route.path, - })); const consumerRoutes = routes.map((route) => ({ name: route.name, path: route.consumerPath, })); - // Write to ui-mobile-playground package. This includes the route paths that consumers would use. - await writeFile({ - data: { routes: consumerRoutes }, - template: 'mobileRoutes.ejs', - dest: `packages/ui-mobile-playground/src/routes.ts`, - }); - - // Write to mobile-app. This is required for hot reload - internal packages need src in path for hot reload, while consumers do not. - await writeFile({ - data: { routes: hotReloadRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/src/routes.ts`, - }); - - // Write to mobile-app. This is required for hot reload - internal packages need src in path for hot reload, while consumers do not. - await writeFile({ - data: { routes: hotReloadRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/src/routes.ts`, - }); - - // Write to mobile-app. This is required for evaluating which routes to run during visreg testing. - await writeFile({ - data: { routes: consumerRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/scripts/utils/routes.mjs`, - }); - - // Write to mobile-app. This is required for evaluating which routes to run during visreg testing. + // Write to expo-app for Expo demo app await writeFile({ data: { routes: consumerRoutes }, template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/scripts/utils/routes.mjs`, + dest: `apps/expo-app/src/routes.ts`, }); } catch (err) { if (err instanceof Error) { diff --git a/libs/codegen/src/templates/partials/objectMap.ejs.t b/libs/codegen/src/templates/partials/objectMap.ejs.t deleted file mode 100644 index 617d89a352..0000000000 --- a/libs/codegen/src/templates/partials/objectMap.ejs.t +++ /dev/null @@ -1,21 +0,0 @@ -<% -let config = locals.config || { commonJS: false, defaultExport: false, disableAsConst: false, sort: false }; - -let types = locals.types || {}; - -const prependedText = config.disableAsConst ? ';' : 'as const;'; - -const items = config.sort ? Object.entries(data).sort(([prevKey], [nextKey]) => prevKey.localeCompare(nextKey)) : Object.entries(data); - -%> - -<% if (config.commonJS){ %> - module.exports = <%- JSON.stringify(data) %> -<% } else if (config.defaultExport){ %> - export default <%- JSON.stringify(data) %> <%- prependedText %> -<% } else { %> -<%_ items.map(([name, value]) => { _%> - export const <%- name %><%- types[name] ? `: ${types[name]}` : `` %> = <%- JSON.stringify(value) %> <%- prependedText %> - -<%_ }) _%> -<% } %> \ No newline at end of file diff --git a/libs/codegen/src/templates/partials/typescript.ejs.t b/libs/codegen/src/templates/partials/typescript.ejs.t deleted file mode 100644 index a2fbff0240..0000000000 --- a/libs/codegen/src/templates/partials/typescript.ejs.t +++ /dev/null @@ -1,11 +0,0 @@ -<% if(data){ %> - <%_ Object.entries(data).map(([name, value]) => { _%> - <% if(typeof value === 'object' && value.length){ %> - export type <%- name %> = <%- value.map(h.format).join('|') -%>; - <% } %> - <% if(typeof value === 'string'){ %> - export type <%- name %> = <%- value %>; - <% } %> - - <%_ }) _%> -<% } %> \ No newline at end of file diff --git a/libs/codegen/src/utils/getHeaderCommentForFileType.ts b/libs/codegen/src/utils/getHeaderCommentForFileType.ts index c0a26fa486..0911ef78df 100644 --- a/libs/codegen/src/utils/getHeaderCommentForFileType.ts +++ b/libs/codegen/src/utils/getHeaderCommentForFileType.ts @@ -10,7 +10,7 @@ export const getHeaderCommentForFileType = (ext: string) => { return ` /** * DO NOT MODIFY - * Generated from scripts/codegen/main.ts + * Generated from libs/codegen */ `; } diff --git a/libs/docusaurus-plugin-docgen/package.json b/libs/docusaurus-plugin-docgen/package.json index 5e988e810a..89d5e0da1d 100644 --- a/libs/docusaurus-plugin-docgen/package.json +++ b/libs/docusaurus-plugin-docgen/package.json @@ -46,10 +46,14 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/ejs": "^3.1.0", - "@types/lodash": "^4.14.178" + "@types/lodash": "^4.14.178", + "fast-glob": "^3.2.11", + "lodash": "^4.17.21", + "prettier": "^3.6.2", + "type-fest": "^2.19.0" } } diff --git a/libs/docusaurus-plugin-kbar/package.json b/libs/docusaurus-plugin-kbar/package.json index 6d32eeef4a..aa9487fbaa 100644 --- a/libs/docusaurus-plugin-kbar/package.json +++ b/libs/docusaurus-plugin-kbar/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/docusaurus-plugin-llm-dev-server/package.json b/libs/docusaurus-plugin-llm-dev-server/package.json index a5140a1d2e..84bc994fb1 100644 --- a/libs/docusaurus-plugin-llm-dev-server/package.json +++ b/libs/docusaurus-plugin-llm-dev-server/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/express": "^4.17.21" diff --git a/libs/eslint-plugin-internal/README.md b/libs/eslint-plugin-internal/README.md index 9fdc7a32a8..553a2375a8 100644 --- a/libs/eslint-plugin-internal/README.md +++ b/libs/eslint-plugin-internal/README.md @@ -100,6 +100,16 @@ We have encountered situations where developers accidentally forgot to destructu At this time this rule is intended to only be used within this repo in the cds-web and cds-mobile packages. However, after a trial period we may consider opening it up to a wider audience. +## spread-props-last + +Requires JSX spread props that come from a component's own `props` parameter to appear after all explicit JSX props in an element. + +This helps avoid accidental prop overrides and keeps prop ordering predictable: + +- Good: ` + + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-chat-toolbar-actions.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-chat-toolbar-actions.output.tsx new file mode 100644 index 0000000000..97863384be --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-chat-toolbar-actions.output.tsx @@ -0,0 +1,27 @@ +import { Button, IconButton } from '@coinbase/cds-web/buttons'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +/** Representative pattern: header IconButton + centered tertiary Button in a vertical stack. */ +export function ChatToolbarActions() { + const handleBack = () => {}; + const handleLoadOlder = () => {}; + + return ( + + + + + + Conversation + + + + + + + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.input.tsx new file mode 100644 index 0000000000..6f24878f50 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.input.tsx @@ -0,0 +1,31 @@ +import { Button } from '@coinbase/cds-web/buttons'; +import { Box, VStack } from '@coinbase/cds-web/layout'; +import { SpotSquare } from '@coinbase/cds-web/illustrations/SpotSquare'; +import { TextBody, TextTitle3 } from '@coinbase/cds-web/typography'; + +/** Representative pattern: stacked layout + illustration + tertiary action after a confirmation. */ +export function SurveyConfirmationPanel() { + const handleAction = () => {}; + + return ( + + + + Submitted + + Your response was recorded. + + + + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.output.tsx new file mode 100644 index 0000000000..7d40708783 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/e2e-survey-confirmation-panel.output.tsx @@ -0,0 +1,31 @@ +import { Button } from '@coinbase/cds-web/buttons'; +import { Box, VStack } from '@coinbase/cds-web/layout'; +import { SpotSquare } from '@coinbase/cds-web/illustrations/SpotSquare'; +import { TextBody, TextTitle3 } from '@coinbase/cds-web/typography'; + +/** Representative pattern: stacked layout + illustration + tertiary action after a confirmation. */ +export function SurveyConfirmationPanel() { + const handleAction = () => {}; + + return ( + + + + Submitted + + Your response was recorded. + + + + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.input.tsx new file mode 100644 index 0000000000..d1dab7c839 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.input.tsx @@ -0,0 +1,53 @@ +import type { CSSProperties } from 'react'; + +import { Pressable } from '@coinbase/cds-web/pressable'; +import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; + +/** Representative pattern: component applying multiple interactable CSS var overrides. */ + +const baseOverrides: CSSProperties = { + '--interactable-background': 'transparent', + '--interactable-border-color': 'var(--color-lineMuted)', +} as CSSProperties; + +function buildStateOverrides(primaryColor: string, pressedColor: string): CSSProperties { + return { + ['--interactable-hovered-background' as keyof CSSProperties]: primaryColor, + ['--interactable-pressed-background' as keyof CSSProperties]: pressedColor, + ['--interactable-hovered-opacity' as keyof CSSProperties]: '0.98', + ['--interactable-pressed-opacity' as keyof CSSProperties]: '0.92', + }; +} + +function buildDisabledOverrides(): CSSProperties { + return { + '--interactable-disabled-background': 'transparent', + '--interactable-disabled-border-color': 'var(--color-lineDisabled)', + } as CSSProperties; +} + +export function ThemedPressableCard({ children }: { children: React.ReactNode }) { + const theme = useTheme(); + + const borderRadiusOverride = { + '--interactable-border-radius': theme.radii.medium, + } as CSSProperties; + + const stateOverrides = buildStateOverrides( + 'var(--color-bgSecondaryHovered)', + 'var(--color-bgSecondaryPressed)', + ); + + return ( + + {children} + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.output.tsx new file mode 100644 index 0000000000..e34d5abd1c --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-interactable-css-vars/e2e-pressable-style-overrides.output.tsx @@ -0,0 +1,53 @@ +import type { CSSProperties } from 'react'; + +import { Pressable } from '@coinbase/cds-web/pressable'; +import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; + +/** Representative pattern: component applying multiple interactable CSS var overrides. */ + +const baseOverrides: CSSProperties = { + "--inter-bg": 'transparent', + "--inter-borderColor": 'var(--color-lineMuted)', +} as CSSProperties; + +function buildStateOverrides(primaryColor: string, pressedColor: string): CSSProperties { + return { + ["--inter-hover-bg" as keyof CSSProperties]: primaryColor, + ["--inter-press-bg" as keyof CSSProperties]: pressedColor, + ["--inter-hover-opacity" as keyof CSSProperties]: '0.98', + ["--inter-press-opacity" as keyof CSSProperties]: '0.92', + }; +} + +function buildDisabledOverrides(): CSSProperties { + return { + "--inter-disable-bg": 'transparent', + "--inter-disable-borderColor": 'var(--color-lineDisabled)', + } as CSSProperties; +} + +export function ThemedPressableCard({ children }: { children: React.ReactNode }) { + const theme = useTheme(); + + const borderRadiusOverride = { + "--inter-borderRadius": theme.radii.medium, + } as CSSProperties; + + const stateOverrides = buildStateOverrides( + 'var(--color-bgSecondaryHovered)', + 'var(--color-bgSecondaryPressed)', + ); + + return ( + + {children} + + ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.input.tsx new file mode 100644 index 0000000000..39121536f8 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.input.tsx @@ -0,0 +1,7 @@ +import type { DimensionValue, PositionStyles, SharedProps } from '@coinbase/cds-common/types'; + +/** Composite sheet-style props mixing `SharedProps` with layout types from common (`@coinbase`). */ +export type BottomSheetLayoutProps = SharedProps & { + maxHeight?: DimensionValue; + contentInset?: PositionStyles; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.output.tsx new file mode 100644 index 0000000000..782159f4c9 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-mobile/sheet-layout-props-from-common.output.tsx @@ -0,0 +1,9 @@ +import type { DimensionValue } from "react-native"; +import type { PositionStyles } from "@coinbase/cds-mobile/styles/styleProps"; +import type { SharedProps } from '@coinbase/cds-common/types'; + +/** Composite sheet-style props mixing `SharedProps` with layout types from common (`@coinbase`). */ +export type BottomSheetLayoutProps = SharedProps & { + maxHeight?: DimensionValue; + contentInset?: PositionStyles; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.input.tsx new file mode 100644 index 0000000000..9d8fc717b7 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.input.tsx @@ -0,0 +1,10 @@ +import type { DimensionValue, PositionStyles, SharedProps } from '@coinbase/cds-common/types'; + +/** + * Composite props: `SharedProps` stays on common; `PositionStyles` + `DimensionValue` migrate + * (overlay-style layout props). + */ +export type ModalLikeProps = SharedProps & { + position?: PositionStyles; + width?: DimensionValue; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.output.tsx new file mode 100644 index 0000000000..62a32ca022 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-layout-types-web/modal-like-props-from-common.output.tsx @@ -0,0 +1,13 @@ +import type { PositionStyles } from "@coinbase/cds-web/styles/styleProps"; +import type { SharedProps } from '@coinbase/cds-common/types'; + +type DimensionValue = string | number | 'auto'; + +/** + * Composite props: `SharedProps` stays on common; `PositionStyles` + `DimensionValue` migrate + * (overlay-style layout props). + */ +export type ModalLikeProps = SharedProps & { + position?: PositionStyles; + width?: DimensionValue; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.input.tsx new file mode 100644 index 0000000000..848730c91e --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.input.tsx @@ -0,0 +1,18 @@ +import { useCallback, useRef } from 'react'; +import type { ScrollView } from 'react-native'; +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +/** Representative pattern: hook merges a caller ref with an internal ref for imperative APIs. */ +type AnyRef = + | React.Ref + | ((node: T | null | undefined) => void) + | React.MutableRefObject; + +export const useScrollTo = (ref?: AnyRef) => { + const internalRef = useRef(undefined); + const scrollRef = useMergeRefs(ref, internalRef); + const scrollTo = useCallback(() => { + internalRef.current?.scrollTo({ x: 0, y: 0, animated: true }); + }, []); + return [scrollRef, scrollTo] as const; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.output.tsx new file mode 100644 index 0000000000..9a2d26c1fb --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-mobile-scroll-to-hook.output.tsx @@ -0,0 +1,18 @@ +import { useCallback, useRef } from 'react'; +import type { ScrollView } from 'react-native'; +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +/** Representative pattern: hook merges a caller ref with an internal ref for imperative APIs. */ +type AnyRef = + | React.Ref + | ((node: T | null | undefined) => void) + | React.MutableRefObject; + +export const useScrollTo = (ref?: AnyRef) => { + const internalRef = useRef(undefined); + const scrollRef = mergeRefs(ref, internalRef); + const scrollTo = useCallback(() => { + internalRef.current?.scrollTo({ x: 0, y: 0, animated: true }); + }, []); + return [scrollRef, scrollTo] as const; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.input.tsx new file mode 100644 index 0000000000..d9f323b8ae --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.input.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo, useRef } from 'react'; +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +/** Representative pattern: `forwardRef` + `useMergeRefs` to combine external and internal refs. */ +type DivProps = React.ComponentPropsWithoutRef<'div'>; + +export const Linkish = memo( + forwardRef((props, ref) => { + const { children, ...rest } = props; + const linkRef = useRef(null); + const mergedRef = useMergeRefs(ref, linkRef); + return ( +
+ {children} +
+ ); + }), +); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.output.tsx new file mode 100644 index 0000000000..c805c73e99 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/cds-web-link-forwardref.output.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo, useRef } from 'react'; +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +/** Representative pattern: `forwardRef` + `useMergeRefs` to combine external and internal refs. */ +type DivProps = React.ComponentPropsWithoutRef<'div'>; + +export const Linkish = memo( + forwardRef((props, ref) => { + const { children, ...rest } = props; + const linkRef = useRef(null); + const mergedRef = mergeRefs(ref, linkRef); + return ( +
+ {children} +
+ ); + }), +); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.input.tsx new file mode 100644 index 0000000000..0dabaa7ceb --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.input.tsx @@ -0,0 +1,57 @@ +import { useCallback, useRef, useState } from 'react'; + +import { useToggler } from '@coinbase/cds-common/hooks/useToggler'; +import type { ChartData, ChartDataPoint, ChartScrubParams, SparklineInteractiveHeaderRef, SparklineInteractiveSubHead } from '@coinbase/cds-web-visualization'; +import { Sparkline, SparklineArea, SparklineGradient, SparklineInteractive, SparklineInteractiveHeader } from '@coinbase/cds-web-visualization'; +import { CartesianChart, PeriodSelector } from '@coinbase/cds-web-visualization/chart'; +import { SparklineInteractiveContent } from '@coinbase/cds-web-visualization/sparkline'; +import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; + +/** Representative pattern: chart hook using root-barrel type imports + named sub-path imports. */ +type ChartPeriod = 'hour' | 'day' | 'week' | 'month' | 'year'; + +type UseSparklineChartParams = { + defaultLabel: string; + defaultTitle: string; + defaultSubHead?: SparklineInteractiveSubHead; +}; + +export function useSparklineChart({ + defaultLabel, + defaultTitle, + defaultSubHead, +}: UseSparklineChartParams) { + const theme = useTheme(); + const headerRef = useRef(null); + const [activePeriod, setActivePeriod] = useState('day'); + const [isScrubbing, { toggleOn: handleScrubStart, toggleOff: handleScrubEnd }] = useToggler(false); + + const handleScrub = useCallback( + ({ point }: ChartScrubParams) => { + headerRef.current?.update({ title: String(point.value) }); + }, + [], + ); + + const handlePointSelect = useCallback( + ({ value }: ChartDataPoint) => { + setActivePeriod('day'); + headerRef.current?.update({ title: String(value) }); + }, + [], + ); + + return { + theme, + headerRef, + activePeriod, + isScrubbing, + handleScrubStart, + handleScrubEnd, + handleScrub, + handlePointSelect, + }; +} + +export type { ChartData, ChartDataPoint }; +export { CartesianChart, PeriodSelector, Sparkline, SparklineArea, SparklineGradient, SparklineInteractive, SparklineInteractiveHeader, SparklineInteractiveContent }; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.output.tsx new file mode 100644 index 0000000000..32dba30d81 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-visualization-imports/e2e-chart-hook.output.tsx @@ -0,0 +1,57 @@ +import { useCallback, useRef, useState } from 'react'; + +import { useToggler } from '@coinbase/cds-common/hooks/useToggler'; +import type { ChartData, ChartDataPoint, ChartScrubParams, SparklineInteractiveHeaderRef, SparklineInteractiveSubHead } from "@coinbase/cds-web/visualizations"; +import { Sparkline, SparklineArea, SparklineGradient, SparklineInteractive, SparklineInteractiveHeader } from "@coinbase/cds-web/visualizations"; +import { CartesianChart, PeriodSelector } from "@coinbase/cds-web/visualizations/chart"; +import { SparklineInteractiveContent } from "@coinbase/cds-web/visualizations/sparkline"; +import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; + +/** Representative pattern: chart hook using root-barrel type imports + named sub-path imports. */ +type ChartPeriod = 'hour' | 'day' | 'week' | 'month' | 'year'; + +type UseSparklineChartParams = { + defaultLabel: string; + defaultTitle: string; + defaultSubHead?: SparklineInteractiveSubHead; +}; + +export function useSparklineChart({ + defaultLabel, + defaultTitle, + defaultSubHead, +}: UseSparklineChartParams) { + const theme = useTheme(); + const headerRef = useRef(null); + const [activePeriod, setActivePeriod] = useState('day'); + const [isScrubbing, { toggleOn: handleScrubStart, toggleOff: handleScrubEnd }] = useToggler(false); + + const handleScrub = useCallback( + ({ point }: ChartScrubParams) => { + headerRef.current?.update({ title: String(point.value) }); + }, + [], + ); + + const handlePointSelect = useCallback( + ({ value }: ChartDataPoint) => { + setActivePeriod('day'); + headerRef.current?.update({ title: String(value) }); + }, + [], + ); + + return { + theme, + headerRef, + activePeriod, + isScrubbing, + handleScrubStart, + handleScrubEnd, + handleScrub, + handlePointSelect, + }; +} + +export type { ChartData, ChartDataPoint }; +export { CartesianChart, PeriodSelector, Sparkline, SparklineArea, SparklineGradient, SparklineInteractive, SparklineInteractiveHeader, SparklineInteractiveContent }; diff --git a/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts b/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts new file mode 100644 index 0000000000..221b5b349c --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts @@ -0,0 +1,349 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../button-variant-values'; + +const FIXTURE_SUITE = 'button-variant-values'; + +/** Only consumer-style E2E goldens (all other cases are inline below). */ +const E2E_PAIRED_PREFIXES = [ + `${FIXTURE_SUITE}/e2e-survey-confirmation-panel`, + `${FIXTURE_SUITE}/e2e-chat-toolbar-actions`, +] as const; + +describe('button-variant-values', () => { + it('rewrites variant="tertiary" to variant="inverse" on Button from @coinbase/cds-web/buttons', () => { + runInlineTest( + transform, + {}, + { + path: 'web-buttons.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = () => ; +`, + }, + ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('does not rewrite Button imported from package root @coinbase/cds-web (not a v8 export)', () => { + runInlineTest( + transform, + {}, + { + path: 'root-web.tsx', + source: ` +import { Button } from '@coinbase/cds-web'; +const App = () => ; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not rewrite IconButton imported from package root @coinbase/cds-mobile (not a v8 export)', () => { + runInlineTest( + transform, + {}, + { + path: 'root-mobile.tsx', + source: ` +import { IconButton } from '@coinbase/cds-mobile'; +const App = () => ; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on Button from @coinbase/cds-web/buttons', () => { + runInlineTest( + transform, + {}, + { + path: 'fg-muted.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = () => ; +`, + }, + ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('rewrites variant="tertiary" to variant="inverse" on Button from a non-@coinbase scope', () => { + runInlineTest( + transform, + {}, + { + path: 'example-scope.tsx', + source: ` +import { Button } from '@example/cds-web/buttons'; +const App = () => ; +`, + }, + ` +import { Button } from '@example/cds-web/buttons'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('skips non-matching scope when --package-scope is set', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { + path: 'scope.tsx', + source: ` +import { Button } from '@example/cds-web/buttons'; +const App = () => ; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('rewrites variant="tertiary" to variant="inverse" on IconButton from @coinbase/cds-mobile', () => { + runInlineTest( + transform, + {}, + { + path: 'icon-tertiary.tsx', + source: ` +import { IconButton } from '@coinbase/cds-mobile/buttons'; +const App = () => ; +`, + }, + ` +import { IconButton } from '@coinbase/cds-mobile/buttons'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on IconButton from @coinbase/cds-mobile', () => { + runInlineTest( + transform, + {}, + { + path: 'icon-fg-muted.tsx', + source: ` +import { IconButton } from '@coinbase/cds-mobile/buttons'; +const App = () => ; +`, + }, + ` +import { IconButton } from '@coinbase/cds-mobile/buttons'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('adds a TODO comment for dynamic variant expressions', () => { + runInlineTest( + transform, + {}, + { + path: 'dynamic.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = ({ v }) => ; +`, + }, + ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = ({ v }) => // TODO [cds-migrator:button-variant-values]: Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. +; +`, + tsxTestOptions, + ); + }); + + it('does not add duplicate TODO if already present', () => { + runInlineTest( + transform, + {}, + { + path: 'dup-todo.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = ({ v }) => + // TODO [cds-migrator:button-variant-values]: Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + ; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('returns empty string when there are no CDS button imports (local relative path)', () => { + runInlineTest( + transform, + {}, + { + path: 'local-button.tsx', + source: ` +import { Button } from './MyButton'; +const App = () => ; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('returns empty string when there are no CDS button imports (non-CDS path)', () => { + runInlineTest( + transform, + {}, + { + path: 'local-components.tsx', + source: `import React from 'react'; +import { Button } from './components/Button'; + +export function CustomToolbar() { + return ( +
+ + +
+ ); +} +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not modify non-CDS Button components', () => { + runInlineTest( + transform, + {}, + { + path: 'third-party.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +import { Button as ThirdPartyButton } from 'third-party-lib'; +const App = () => ( + <> + + Other + +); +`, + }, + ` +import { Button } from '@coinbase/cds-web/buttons'; +import { Button as ThirdPartyButton } from 'third-party-lib'; +const App = () => ( + <> + + Other + +); +`, + tsxTestOptions, + ); + }); + + it('does not modify already-correct variant values', () => { + runInlineTest( + transform, + {}, + { + path: 'already-correct.tsx', + source: ` +import { Button } from '@coinbase/cds-web/buttons'; +const App = () => ( + <> + + + + +); +`, + }, + '', + tsxTestOptions, + ); + }); + + it('transforms aliased CDS Button imports', () => { + runInlineTest( + transform, + {}, + { + path: 'alias.tsx', + source: ` +import { Button as CdsButton } from '@coinbase/cds-web/buttons'; +const App = () => Click; +`, + }, + ` +import { Button as CdsButton } from '@coinbase/cds-web/buttons'; +const App = () => Click; +`, + tsxTestOptions, + ); + }); + + it('produces the same result when run twice', () => { + /** Output after one pass on the idempotent test input (second pass must no-op). */ + const BUTTON_ICONBUTTON_FIRST_PASS = ` +import { Button, IconButton } from '@coinbase/cds-web/buttons'; +const App = () => ( + <> + + + +); +`; + runInlineTest( + transform, + {}, + { + path: 'idempotent.tsx', + source: ` +import { Button, IconButton } from '@coinbase/cds-web/buttons'; +const App = () => ( + <> + + + +); +`, + }, + BUTTON_ICONBUTTON_FIRST_PASS, + tsxTestOptions, + ); + runInlineTest( + transform, + {}, + { path: 'idempotent-pass2.tsx', source: BUTTON_ICONBUTTON_FIRST_PASS }, + '', + tsxTestOptions, + ); + }); + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, 'button-variant-values', {}, prefix, tsxTestOptions); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars-css.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars-css.test.ts new file mode 100644 index 0000000000..ef581cc847 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars-css.test.ts @@ -0,0 +1,266 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + applyRenames, + DEFAULT_IGNORE_PATTERNS, + parseIgnorePatterns, + processDirectory, + VAR_RENAMES, +} from '../migrate-interactable-css-vars-css'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cds-migrator-css-test-')); +} + +function writeFile(dir: string, relativePath: string, content: string): string { + const fullPath = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + return fullPath; +} + +function readFile(dir: string, relativePath: string): string { + return fs.readFileSync(path.join(dir, relativePath), 'utf8'); +} + +// --------------------------------------------------------------------------- +// applyRenames — pure unit tests +// --------------------------------------------------------------------------- + +describe('applyRenames', () => { + it('renames all 11 CSS vars', () => { + const input = VAR_RENAMES.map(([oldVar]) => `--custom: var(${oldVar});`).join('\n'); + const output = VAR_RENAMES.map(([, newVar]) => `--custom: var(${newVar});`).join('\n'); + expect(applyRenames(input)).toBe(output); + }); + + it('renames CSS var declarations', () => { + expect(applyRenames('--interactable-background: transparent;')).toBe( + '--inter-bg: transparent;', + ); + }); + + it('renames CSS var references inside var()', () => { + expect(applyRenames('background: var(--interactable-background);')).toBe( + 'background: var(--inter-bg);', + ); + }); + + it('renames multiple vars in a single line', () => { + expect( + applyRenames('--interactable-background: blue; --interactable-pressed-background: darkblue;'), + ).toBe('--inter-bg: blue; --inter-press-bg: darkblue;'); + }); + + it('renames vars across multiple lines', () => { + const input = ` +.button { + --interactable-background: transparent; + --interactable-hovered-background: rgb(250, 250, 250); + --interactable-pressed-background: rgb(235, 235, 236); +}`; + const output = ` +.button { + --inter-bg: transparent; + --inter-hover-bg: rgb(250, 250, 250); + --inter-press-bg: rgb(235, 235, 236); +}`; + expect(applyRenames(input)).toBe(output); + }); + + it('does not match partial names that are not CSS vars', () => { + expect(applyRenames('interactable-background')).toBe('interactable-background'); + expect(applyRenames('--not-interactable-background')).toBe('--not-interactable-background'); + }); + + it('is idempotent', () => { + const input = '--inter-bg: transparent; --inter-press-bg: blue;'; + expect(applyRenames(input)).toBe(input); + }); + + it('returns the original string unchanged when there are no matches', () => { + const input = '.btn { background: red; border-radius: 8px; }'; + expect(applyRenames(input)).toBe(input); + }); +}); + +// --------------------------------------------------------------------------- +// parseIgnorePatterns +// --------------------------------------------------------------------------- + +describe('parseIgnorePatterns', () => { + it('returns DEFAULT_IGNORE_PATTERNS when no --ignore-pattern= args are present', () => { + expect(parseIgnorePatterns([])).toEqual(DEFAULT_IGNORE_PATTERNS); + expect(parseIgnorePatterns(['--dry'])).toEqual(DEFAULT_IGNORE_PATTERNS); + }); + + it('parses a single --ignore-pattern= arg', () => { + expect(parseIgnorePatterns(['--ignore-pattern=**/dist/**'])).toEqual(['**/dist/**']); + }); + + it('parses multiple --ignore-pattern= args', () => { + expect( + parseIgnorePatterns([ + '--ignore-pattern=**/node_modules/**', + '--ignore-pattern=**/.next/**', + '--dry', + ]), + ).toEqual(['**/node_modules/**', '**/.next/**']); + }); + + it('ignores args that do not start with --ignore-pattern=', () => { + expect(parseIgnorePatterns(['--dry', '--ignore-pattern=**/dist/**', '--unknown=foo'])).toEqual([ + '**/dist/**', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// processDirectory — integration tests using a real temp directory +// --------------------------------------------------------------------------- + +describe('processDirectory', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites CSS files in place', async () => { + writeFile( + tmpDir, + 'styles.css', + '--interactable-background: transparent;\n--interactable-hovered-background: rgb(250,250,250);\n', + ); + + const result = await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'styles.css')).toBe( + '--inter-bg: transparent;\n--inter-hover-bg: rgb(250,250,250);\n', + ); + expect(result).toEqual({ processed: 1, changed: 1 }); + }); + + it('rewrites SCSS files in place', async () => { + writeFile(tmpDir, 'theme.scss', '.btn { --interactable-pressed-background: blue; }\n'); + + await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'theme.scss')).toBe('.btn { --inter-press-bg: blue; }\n'); + }); + + it('rewrites HTML inline style attributes', async () => { + writeFile( + tmpDir, + 'index.html', + '
\n', + ); + + await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'index.html')).toBe( + '
\n', + ); + }); + + it('processes files in nested directories', async () => { + writeFile(tmpDir, 'a/b/deep.css', '--interactable-border-color: red;\n'); + + const result = await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'a/b/deep.css')).toBe('--inter-borderColor: red;\n'); + expect(result.changed).toBe(1); + }); + + it('does not modify files with no matching vars', async () => { + const original = '.btn { background: blue; border-radius: 8px; }\n'; + writeFile(tmpDir, 'unchanged.css', original); + + const result = await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'unchanged.css')).toBe(original); + expect(result).toEqual({ processed: 1, changed: 0 }); + }); + + it('does not modify JS/TS files', async () => { + const jsContent = "const bg = '--interactable-background';\n"; + writeFile(tmpDir, 'styles.ts', jsContent); + + const result = await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'styles.ts')).toBe(jsContent); + expect(result.processed).toBe(0); + }); + + it('does not write files when dryRun is true', async () => { + const original = '--interactable-background: transparent;\n'; + writeFile(tmpDir, 'styles.css', original); + + const result = await processDirectory(tmpDir, true); + + expect(readFile(tmpDir, 'styles.css')).toBe(original); + expect(result).toEqual({ processed: 1, changed: 1 }); + }); + + it('is idempotent: second run is a no-op', async () => { + writeFile(tmpDir, 'styles.css', '--interactable-background: transparent;\n'); + + await processDirectory(tmpDir, false); + const afterFirstPass = readFile(tmpDir, 'styles.css'); + + const secondResult = await processDirectory(tmpDir, false); + + expect(readFile(tmpDir, 'styles.css')).toBe(afterFirstPass); + expect(secondResult).toEqual({ processed: 1, changed: 0 }); + }); + + it('skips node_modules by default', async () => { + writeFile( + tmpDir, + 'node_modules/some-lib/styles.css', + '--interactable-background: transparent;\n', + ); + + const result = await processDirectory(tmpDir, false); + + expect(result.processed).toBe(0); + }); + + it('respects custom ignore patterns forwarded by the runner', async () => { + writeFile(tmpDir, 'generated/styles.css', '--interactable-background: transparent;\n'); + writeFile(tmpDir, 'src/styles.css', '--interactable-background: transparent;\n'); + + const result = await processDirectory(tmpDir, false, ['**/generated/**']); + + expect(result.processed).toBe(1); + expect(result.changed).toBe(1); + // generated/ was ignored — file is unchanged + expect(readFile(tmpDir, 'generated/styles.css')).toBe( + '--interactable-background: transparent;\n', + ); + // src/ was processed + expect(readFile(tmpDir, 'src/styles.css')).toBe('--inter-bg: transparent;\n'); + }); + + it('reports correct counts for mixed files', async () => { + writeFile(tmpDir, 'a.css', '--interactable-background: blue;\n'); + writeFile(tmpDir, 'b.css', '.btn { color: red; }\n'); + writeFile(tmpDir, 'c.scss', '--interactable-hovered-background: green;\n'); + + const result = await processDirectory(tmpDir, false); + + expect(result).toEqual({ processed: 3, changed: 2 }); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars.test.ts new file mode 100644 index 0000000000..966689a45e --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-interactable-css-vars.test.ts @@ -0,0 +1,224 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../migrate-interactable-css-vars'; + +const FIXTURE_SUITE = 'migrate-interactable-css-vars'; + +const E2E_PAIRED_PREFIXES = [`${FIXTURE_SUITE}/e2e-pressable-style-overrides`] as const; + +describe('migrate-interactable-css-vars', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renames all 11 CSS vars as standalone string literal keys', () => { + runInlineTest( + transform, + {}, + { + path: 'all-vars.tsx', + source: ` +const styles = { + '--interactable-border-radius': '8px', + '--interactable-background': 'transparent', + '--interactable-border-color': 'red', + '--interactable-pressed-background': 'blue', + '--interactable-pressed-border-color': 'green', + '--interactable-pressed-opacity': '0.9', + '--interactable-hovered-background': 'yellow', + '--interactable-hovered-border-color': 'purple', + '--interactable-hovered-opacity': '0.8', + '--interactable-disabled-background': 'gray', + '--interactable-disabled-border-color': 'lightgray', +}; +`, + }, + ` +const styles = { + "--inter-borderRadius": '8px', + "--inter-bg": 'transparent', + "--inter-borderColor": 'red', + "--inter-press-bg": 'blue', + "--inter-press-borderColor": 'green', + "--inter-press-opacity": '0.9', + "--inter-hover-bg": 'yellow', + "--inter-hover-borderColor": 'purple', + "--inter-hover-opacity": '0.8', + "--inter-disable-bg": 'gray', + "--inter-disable-borderColor": 'lightgray', +}; +`, + tsxTestOptions, + ); + }); + + it('renames CSS var used as a JSX inline style prop key', () => { + runInlineTest( + transform, + {}, + { + path: 'style-prop.tsx', + source: ` +const Panel = () => ( +
+); +`, + }, + ` +const Panel = () => ( +
+); +`, + tsxTestOptions, + ); + }); + + it('renames CSS var used as a computed key with type cast', () => { + runInlineTest( + transform, + {}, + { + path: 'computed-key.tsx', + source: ` +import type { CSSProperties } from 'react'; +const overrides: CSSProperties = { + ['--interactable-hovered-background' as keyof CSSProperties]: 'var(--color-secondaryHovered)', + ['--interactable-pressed-background' as keyof CSSProperties]: 'var(--color-secondaryPressed)', +}; +`, + }, + ` +import type { CSSProperties } from 'react'; +const overrides: CSSProperties = { + ["--inter-hover-bg" as keyof CSSProperties]: 'var(--color-secondaryHovered)', + ["--inter-press-bg" as keyof CSSProperties]: 'var(--color-secondaryPressed)', +}; +`, + tsxTestOptions, + ); + }); + + it('renames CSS vars inside var() references in string literal values', () => { + runInlineTest( + transform, + {}, + { + path: 'var-ref.tsx', + source: ` +const styles = { + background: 'var(--interactable-background)', + border: '1px solid var(--interactable-border-color)', +}; +`, + }, + ` +const styles = { + background: "var(--inter-bg)", + border: "1px solid var(--inter-borderColor)", +}; +`, + tsxTestOptions, + ); + }); + + it('renames CSS vars inside template literal quasis', () => { + runInlineTest( + transform, + {}, + { + path: 'template-literal.ts', + source: ` +const getButtonStyle = (color: string) => \` + --interactable-background: \${color}; + --interactable-border-color: var(--color-accentBoldRed) !important; +\`; +`, + }, + ` +const getButtonStyle = (color: string) => \` + --inter-bg: \${color}; + --inter-borderColor: var(--color-accentBoldRed) !important; +\`; +`, + tsxTestOptions, + ); + }); + + it('handles multiple old CSS vars in a single string literal', () => { + runInlineTest( + transform, + {}, + { + path: 'multi-var-string.tsx', + source: ` +const style = '--interactable-background: blue; --interactable-pressed-background: darkblue;'; +`, + }, + ` +const style = "--inter-bg: blue; --inter-press-bg: darkblue;"; +`, + tsxTestOptions, + ); + }); + + it('is idempotent: second run is a no-op', () => { + const afterFirstPass = ` +const styles = { + '--inter-bg': 'transparent', + '--inter-press-bg': 'blue', + '--inter-hover-bg': 'yellow', +}; +`; + runInlineTest( + transform, + {}, + { path: 'idempotent.tsx', source: afterFirstPass }, + '', + tsxTestOptions, + ); + }); + + it('is a no-op when no old CSS var names are present', () => { + runInlineTest( + transform, + {}, + { + path: 'no-match.tsx', + source: ` +const styles = { + background: 'var(--color-bgPrimary)', + borderRadius: '8px', +}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not rename partial matches that are not a CSS var name', () => { + runInlineTest( + transform, + {}, + { + path: 'no-partial-match.tsx', + source: ` +const label = 'interactable-background'; +const desc = 'see interactable for more info'; +`, + }, + '', + tsxTestOptions, + ); + }); + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, 'migrate-interactable-css-vars', {}, prefix, tsxTestOptions); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-mobile.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-mobile.test.ts new file mode 100644 index 0000000000..da0c389da1 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-mobile.test.ts @@ -0,0 +1,87 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../migrate-layout-types-mobile'; + +const FIXTURE_SUITE = 'migrate-layout-types-mobile'; +const TRANSFORM_NAME = 'migrate-layout-types-mobile'; + +/** + * Behavioral tests are **inline**. One paired golden covers composite sheet-style props mixing + * `SharedProps` with layout types from common (`@coinbase`). + */ + +const E2E_PAIRED_PREFIXES = [`${FIXTURE_SUITE}/sheet-layout-props-from-common`] as const; + +describe('migrate-layout-types-mobile', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('migrates DimensionValue, PositionStyles, and remaining common type imports', () => { + const input = ` +import type { DimensionValue, LottieStatusAnimationType, PositionStyles } from '@coinbase/cds-common/types'; + +export const w: DimensionValue = 10; +export const x: DimensionValue = 1; +export type P = PositionStyles; +`; + + const output = ` +import type { DimensionValue } from "react-native"; +import type { PositionStyles } from "@coinbase/cds-mobile/styles/styleProps"; +import type { LottieStatusAnimationType } from '@coinbase/cds-common/types'; + +export const w: DimensionValue = 10; +export const x: DimensionValue = 1; +export type P = PositionStyles; +`; + + runInlineTest( + transform, + {}, + { path: 'layout-types.tsx', source: input }, + output, + tsxTestOptions, + ); + }); + + it('makes no changes when a local DimensionValue exists and common DimensionValue is imported under an alias', () => { + const input = ` +type DimensionValue = number; + +import type { DimensionValue as CdsDimensionValue } from '@coinbase/cds-common'; + +export const w: DimensionValue = 1; +export const x: CdsDimensionValue = 2; +`; + runInlineTest(transform, {}, { path: 'local.tsx', source: input }, '', tsxTestOptions); + }); + + it('makes no changes when there are no cds-common layout type imports', () => { + const input = ` +import React from 'react'; + +export const App = () => Hi; +`; + runInlineTest(transform, {}, { path: 'no-cds-common.tsx', source: input }, '', tsxTestOptions); + }); + + it('is idempotent on DimensionValue migration output', () => { + const transformed = ` +import type { DimensionValue } from "react-native"; + +export const w: DimensionValue = 10; +`; + runInlineTest(transform, {}, { path: 'idem.tsx', source: transformed }, '', tsxTestOptions); + }); + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, TRANSFORM_NAME, {}, prefix, tsxTestOptions); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-web.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-web.test.ts new file mode 100644 index 0000000000..88119293a4 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-layout-types-web.test.ts @@ -0,0 +1,88 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../migrate-layout-types-web'; + +const FIXTURE_SUITE = 'migrate-layout-types-web'; +const TRANSFORM_NAME = 'migrate-layout-types-web'; + +/** + * Behavioral tests are **inline**. One paired golden covers composite overlay-style props + * (`SharedProps` + `PositionStyles` + `DimensionValue` from common). + */ + +const LAYOUT_TYPES_INPUT = ` +import type { DimensionValue, LottieStatusAnimationType, PositionStyles } from '@coinbase/cds-common/types'; + +export const w: DimensionValue = 10; +export const x: DimensionValue = 1; +export type P = PositionStyles; +`; + +const LAYOUT_TYPES_OUTPUT = ` +import type { PositionStyles } from "@coinbase/cds-web/styles/styleProps"; +import type { LottieStatusAnimationType } from '@coinbase/cds-common/types'; + +type DimensionValue = string | number | 'auto'; + +export const w: DimensionValue = 10; +export const x: DimensionValue = 1; +export type P = PositionStyles; +`; + +const E2E_PAIRED_PREFIXES = [`${FIXTURE_SUITE}/modal-like-props-from-common`] as const; + +describe('migrate-layout-types-web', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('migrates DimensionValue, PositionStyles, and remaining common type imports', () => { + runInlineTest( + transform, + {}, + { path: 'layout-types.tsx', source: LAYOUT_TYPES_INPUT }, + LAYOUT_TYPES_OUTPUT, + tsxTestOptions, + ); + }); + + it('makes no changes when a local DimensionValue exists and common DimensionValue is imported under an alias', () => { + const input = ` +type DimensionValue = string; + +import type { DimensionValue as CdsDimensionValue } from '@coinbase/cds-common'; + +export const w: DimensionValue = 'x'; +export const x: CdsDimensionValue = 'y'; +`; + runInlineTest(transform, {}, { path: 'local.tsx', source: input }, '', tsxTestOptions); + }); + + it('makes no changes when there are no cds-common layout type imports', () => { + const input = ` +import React from 'react'; + +export const App = () => Hi; +`; + runInlineTest(transform, {}, { path: 'no-cds-common.tsx', source: input }, '', tsxTestOptions); + }); + + it('is idempotent on DimensionValue migration output', () => { + const transformed = ` +type DimensionValue = string | number | 'auto'; + +export const w: DimensionValue = 10; +`; + runInlineTest(transform, {}, { path: 'idem.tsx', source: transformed }, '', tsxTestOptions); + }); + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, TRANSFORM_NAME, {}, prefix, tsxTestOptions); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts new file mode 100644 index 0000000000..233cad3858 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts @@ -0,0 +1,233 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../migrate-use-merge-refs'; + +const FIXTURE_SUITE = 'migrate-use-merge-refs'; +const TRANSFORM_NAME = 'migrate-use-merge-refs'; + +/** + * Behavioral cases are **inline** below. Two larger paired examples: forwardRef + merged refs, and a + * hook that merges caller + internal refs (fictional names; structure only). + */ + +/** Non-@coinbase scope (used only for --package-scope inline tests). */ +const ALTERNATE_SCOPE_SOURCE = ` +import { useMergeRefs } from '@example/cds-common/hooks/useMergeRefs'; + +export const X = () => { + const ref = useMergeRefs(a, b); + return ref; +}; +`; + +const ALTERNATE_SCOPE_EXPECTED = ` +import { mergeRefs } from "@example/cds-common/utils/mergeRefs"; + +export const X = () => { + const ref = mergeRefs(a, b); + return ref; +}; +`; + +const BASIC_TRANSFORMED_OUTPUT = ` +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +export const X = () => { + const ref = mergeRefs(a, b); + return ref; +}; +`; + +const E2E_PAIRED_PREFIXES = [ + `${FIXTURE_SUITE}/cds-web-link-forwardref`, + `${FIXTURE_SUITE}/cds-mobile-scroll-to-hook`, +] as const; + +describe('migrate-use-merge-refs', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('migrates default import from hooks/useMergeRefs to utils/mergeRefs', () => { + runInlineTest( + transform, + {}, + { + path: 'basic.tsx', + source: ` +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +export const X = () => { + const ref = useMergeRefs(a, b); + return ref; +}; +`, + }, + BASIC_TRANSFORMED_OUTPUT, + tsxTestOptions, + ); + }); + + it('preserves import aliases when migrating', () => { + runInlineTest( + transform, + {}, + { + path: 'alias.tsx', + source: ` +import { useMergeRefs as combineRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; +combineRefs(r1, r2); +`, + }, + ` +import { mergeRefs as combineRefs } from "@coinbase/cds-common/utils/mergeRefs"; +combineRefs(r1, r2); +`, + tsxTestOptions, + ); + }); + + it('dedupes when mergeRefs and useMergeRefs were both imported', () => { + runInlineTest( + transform, + {}, + { + path: 'dup.tsx', + source: ` +import { mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +const cb = mergeRefs(useMergeRefs(a)); +`, + }, + ` +import { mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; + +const cb = mergeRefs(mergeRefs(a)); +`, + tsxTestOptions, + ); + }); + + it('migrates re-exports and call sites but skips object literal shorthand keys', () => { + runInlineTest( + transform, + {}, + { + path: 'reexport.tsx', + source: ` +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +export { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +const o = { useMergeRefs: 1 }; +useMergeRefs(r); +`, + }, + ` +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +export { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +const o = { useMergeRefs: 1 }; +mergeRefs(r); +`, + tsxTestOptions, + ); + }); + + it('rewrites jest.mock path and import when targeting the hooks entry', () => { + runInlineTest( + transform, + {}, + { + path: 'jest.tsx', + source: ` +jest.mock('@coinbase/cds-common/hooks/useMergeRefs'); +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; +useMergeRefs(x); +`, + }, + ` +jest.mock("@coinbase/cds-common/utils/mergeRefs"); +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; +mergeRefs(x); +`, + tsxTestOptions, + ); + }); + + it('does not modify third-party useMergeRefs import', () => { + runInlineTest( + transform, + {}, + { + path: 'third-party.tsx', + source: ` +import { useMergeRefs } from 'some-other-library'; + +export function f() { + return useMergeRefs(a, b); +} +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not migrate alternate scope when --package-scope is @coinbase', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { path: 'scope.tsx', source: ALTERNATE_SCOPE_SOURCE }, + '', + tsxTestOptions, + ); + }); + + it('migrates alternate scope when --package-scope matches', () => { + runInlineTest( + transform, + { packageScope: '@example' }, + { path: 'scope.tsx', source: ALTERNATE_SCOPE_SOURCE }, + ALTERNATE_SCOPE_EXPECTED, + tsxTestOptions, + ); + }); + + it('makes no changes when there is nothing to migrate', () => { + runInlineTest( + transform, + {}, + { + path: 'noop.tsx', + source: ` +import React from 'react'; +export const x = 1; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('is idempotent: second run on transformed output makes no changes', () => { + runInlineTest( + transform, + {}, + { path: 'idempotent.tsx', source: BASIC_TRANSFORMED_OUTPUT }, + '', + tsxTestOptions, + ); + }); + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, TRANSFORM_NAME, {}, prefix, tsxTestOptions); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-visualization-imports.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-visualization-imports.test.ts new file mode 100644 index 0000000000..f9c83285bd --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-visualization-imports.test.ts @@ -0,0 +1,305 @@ +import { runInlineTest, runTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../migrate-visualization-imports'; + +const FIXTURE_SUITE = 'migrate-visualization-imports'; + +/** + * E2E paired fixtures — composite real-world patterns (OSS-safe). + * Covers: root-barrel value + type imports, chart/sparkline sub-path imports, + * and non-visualization imports left unchanged. + */ +const E2E_PAIRED_PREFIXES = [`${FIXTURE_SUITE}/e2e-chart-hook`] as const; + +describe('migrate-visualization-imports', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + // ─── Web: root barrel ──────────────────────────────────────────────────────── + + it('rewrites @coinbase/cds-web-visualization root barrel import', () => { + runInlineTest( + transform, + {}, + { + path: 'web-root.tsx', + source: `import { Sparkline } from '@coinbase/cds-web-visualization';`, + }, + `import { Sparkline } from "@coinbase/cds-web/visualizations";`, + tsxTestOptions, + ); + }); + + // ─── Web: named sub-paths ──────────────────────────────────────────────────── + + it('rewrites @coinbase/cds-web-visualization/chart', () => { + runInlineTest( + transform, + {}, + { + path: 'web-chart.tsx', + source: `import { CartesianChart, LineChart } from '@coinbase/cds-web-visualization/chart';`, + }, + `import { CartesianChart, LineChart } from "@coinbase/cds-web/visualizations/chart";`, + tsxTestOptions, + ); + }); + + it('rewrites @coinbase/cds-web-visualization/sparkline', () => { + runInlineTest( + transform, + {}, + { + path: 'web-sparkline.tsx', + source: `import { Sparkline, SparklineArea } from '@coinbase/cds-web-visualization/sparkline';`, + }, + `import { Sparkline, SparklineArea } from "@coinbase/cds-web/visualizations/sparkline";`, + tsxTestOptions, + ); + }); + + it('rewrites deep sub-path @coinbase/cds-web-visualization/chart/area', () => { + runInlineTest( + transform, + {}, + { + path: 'web-deep.tsx', + source: `import { AreaChart } from '@coinbase/cds-web-visualization/chart/area';`, + }, + `import { AreaChart } from "@coinbase/cds-web/visualizations/chart/area";`, + tsxTestOptions, + ); + }); + + // ─── Mobile: root barrel ───────────────────────────────────────────────────── + + it('rewrites @coinbase/cds-mobile-visualization root barrel import', () => { + runInlineTest( + transform, + {}, + { + path: 'mobile-root.tsx', + source: `import { Sparkline } from '@coinbase/cds-mobile-visualization';`, + }, + `import { Sparkline } from "@coinbase/cds-mobile/visualizations";`, + tsxTestOptions, + ); + }); + + // ─── Mobile: named sub-paths ───────────────────────────────────────────────── + + it('rewrites @coinbase/cds-mobile-visualization/chart', () => { + runInlineTest( + transform, + {}, + { + path: 'mobile-chart.tsx', + source: `import { CartesianChart, BarChart } from '@coinbase/cds-mobile-visualization/chart';`, + }, + `import { CartesianChart, BarChart } from "@coinbase/cds-mobile/visualizations/chart";`, + tsxTestOptions, + ); + }); + + it('rewrites @coinbase/cds-mobile-visualization/sparkline', () => { + runInlineTest( + transform, + {}, + { + path: 'mobile-sparkline.tsx', + source: `import { SparklineInteractive } from '@coinbase/cds-mobile-visualization/sparkline';`, + }, + `import { SparklineInteractive } from "@coinbase/cds-mobile/visualizations/sparkline";`, + tsxTestOptions, + ); + }); + + it('rewrites deep sub-path @coinbase/cds-mobile-visualization/chart/bar', () => { + runInlineTest( + transform, + {}, + { + path: 'mobile-deep.tsx', + source: `import { BarChart } from '@coinbase/cds-mobile-visualization/chart/bar';`, + }, + `import { BarChart } from "@coinbase/cds-mobile/visualizations/chart/bar";`, + tsxTestOptions, + ); + }); + + // ─── Re-exports ────────────────────────────────────────────────────────────── + + it('rewrites export * from visualization package', () => { + runInlineTest( + transform, + {}, + { + path: 're-export-all.ts', + source: `export * from '@coinbase/cds-web-visualization/chart';`, + }, + `export * from "@coinbase/cds-web/visualizations/chart";`, + tsxTestOptions, + ); + }); + + it('rewrites named re-export from visualization package', () => { + runInlineTest( + transform, + {}, + { + path: 're-export-named.ts', + source: `export { Sparkline, SparklineArea } from '@coinbase/cds-web-visualization/sparkline';`, + }, + `export { Sparkline, SparklineArea } from "@coinbase/cds-web/visualizations/sparkline";`, + tsxTestOptions, + ); + }); + + // ─── Mixed web + mobile in one file ───────────────────────────────────────── + + it('rewrites both web and mobile visualization imports in the same file', () => { + runInlineTest( + transform, + {}, + { + path: 'mixed.tsx', + source: ` +import { CartesianChart } from '@coinbase/cds-web-visualization/chart'; +import { Sparkline } from '@coinbase/cds-mobile-visualization/sparkline'; +`, + }, + ` +import { CartesianChart } from "@coinbase/cds-web/visualizations/chart"; +import { Sparkline } from "@coinbase/cds-mobile/visualizations/sparkline"; +`, + tsxTestOptions, + ); + }); + + // ─── Scope filtering ───────────────────────────────────────────────────────── + + it('rewrites matching scope when --packageScope is set', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { + path: 'scope-match.tsx', + source: `import { LineChart } from '@coinbase/cds-web-visualization/chart';`, + }, + `import { LineChart } from "@coinbase/cds-web/visualizations/chart";`, + tsxTestOptions, + ); + }); + + it('skips non-matching scope when --packageScope is set', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { + path: 'scope-mismatch.tsx', + source: `import { LineChart } from '@example/cds-web-visualization/chart';`, + }, + '', + tsxTestOptions, + ); + }); + + it('rewrites any scope when --packageScope is omitted', () => { + runInlineTest( + transform, + {}, + { + path: 'any-scope.tsx', + source: `import { AreaChart } from '@example/cds-web-visualization/chart';`, + }, + `import { AreaChart } from "@example/cds-web/visualizations/chart";`, + tsxTestOptions, + ); + }); + + // ─── No-op cases ───────────────────────────────────────────────────────────── + + it('is a no-op when import is already migrated to cds-web/visualizations', () => { + runInlineTest( + transform, + {}, + { + path: 'already-migrated.tsx', + source: `import { CartesianChart } from '@coinbase/cds-web/visualizations/chart';`, + }, + '', + tsxTestOptions, + ); + }); + + it('is a no-op for unrelated imports', () => { + runInlineTest( + transform, + {}, + { + path: 'unrelated.tsx', + source: `import { Button } from '@coinbase/cds-web/buttons';`, + }, + '', + tsxTestOptions, + ); + }); + + it('is a no-op for imports from cds-web (root, non-visualization)', () => { + runInlineTest( + transform, + {}, + { + path: 'cds-web-root.tsx', + source: `import { ThemeProvider } from '@coinbase/cds-web';`, + }, + '', + tsxTestOptions, + ); + }); + + // ─── E2E paired fixtures ────────────────────────────────────────────────────── + + it.each(E2E_PAIRED_PREFIXES)('%s', (prefix) => { + runTest(__dirname, 'migrate-visualization-imports', {}, prefix, tsxTestOptions); + }); + + // ─── Idempotency ───────────────────────────────────────────────────────────── + + it('is idempotent — second run on already-migrated output is a no-op', () => { + const AFTER_FIRST_PASS = ` +import { CartesianChart } from "@coinbase/cds-web/visualizations/chart"; +import { Sparkline } from "@coinbase/cds-mobile/visualizations/sparkline"; +`; + + runInlineTest( + transform, + {}, + { + path: 'idempotent-pass1.tsx', + source: ` +import { CartesianChart } from '@coinbase/cds-web-visualization/chart'; +import { Sparkline } from '@coinbase/cds-mobile-visualization/sparkline'; +`, + }, + AFTER_FIRST_PASS, + tsxTestOptions, + ); + + runInlineTest( + transform, + {}, + { path: 'idempotent-pass2.tsx', source: AFTER_FIRST_PASS }, + '', + tsxTestOptions, + ); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/theme-provider-isolated.test.ts b/packages/migrator/src/transforms/v9/__tests__/theme-provider-isolated.test.ts new file mode 100644 index 0000000000..e80784039e --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/theme-provider-isolated.test.ts @@ -0,0 +1,305 @@ +import { runInlineTest } from 'jscodeshift/src/testUtils'; + +import { tsxTestOptions } from '../../../test-utils/codemodTestUtils'; +import transform from '../theme-provider-isolated'; + +describe('theme-provider-isolated', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('adds isolated to ThemeProvider imported from @coinbase/cds-web root barrel', () => { + runInlineTest( + transform, + {}, + { + path: 'root-barrel.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('adds isolated to ThemeProvider imported from @coinbase/cds-web/system sub-path', () => { + runInlineTest( + transform, + {}, + { + path: 'system-sub-path.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web/system'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web/system'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('adds isolated to ThemeProvider imported from a deep sub-path', () => { + runInlineTest( + transform, + {}, + { + path: 'deep-path.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web/system/ThemeProvider'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web/system/ThemeProvider'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('adds isolated to ThemeProvider from a non-@coinbase scope', () => { + runInlineTest( + transform, + {}, + { + path: 'other-scope.tsx', + source: ` +import { ThemeProvider } from '@example/cds-web'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider } from '@example/cds-web'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('uses local alias name when ThemeProvider is aliased', () => { + runInlineTest( + transform, + {}, + { + path: 'aliased.tsx', + source: ` +import { ThemeProvider as CdsThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider as CdsThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('adds isolated to self-closing ThemeProvider', () => { + runInlineTest( + transform, + {}, + { + path: 'self-closing.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => ; +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => ; +`, + tsxTestOptions, + ); + }); + + it('does not add isolated when ThemeProvider already has isolated prop (boolean shorthand)', () => { + runInlineTest( + transform, + {}, + { + path: 'already-isolated.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not add isolated when ThemeProvider already has isolated={true}', () => { + runInlineTest( + transform, + {}, + { + path: 'already-isolated-explicit.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('does not add isolated when ThemeProvider already has isolated={false}', () => { + runInlineTest( + transform, + {}, + { + path: 'already-isolated-false.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('is a no-op when ThemeProvider comes from a relative import', () => { + runInlineTest( + transform, + {}, + { + path: 'relative-import.tsx', + source: ` +import { ThemeProvider } from './ThemeProvider'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('is a no-op when ThemeProvider comes from a non-CDS package', () => { + runInlineTest( + transform, + {}, + { + path: 'third-party.tsx', + source: ` +import { ThemeProvider } from 'some-other-lib'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('skips non-matching scope when --packageScope is set', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { + path: 'scope-mismatch.tsx', + source: ` +import { ThemeProvider } from '@example/cds-web'; +const App = () => {children}; +`, + }, + '', + tsxTestOptions, + ); + }); + + it('applies when --packageScope matches', () => { + runInlineTest( + transform, + { packageScope: '@coinbase' }, + { + path: 'scope-match.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + tsxTestOptions, + ); + }); + + it('does not modify a ThemeProvider element that is not from a CDS import', () => { + runInlineTest( + transform, + {}, + { + path: 'mixed-imports.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +import { ThemeProvider as OtherTP } from 'other-lib'; +const App = () => ( + <> + {children} + {children} + +); +`, + }, + ` +import { ThemeProvider } from '@coinbase/cds-web'; +import { ThemeProvider as OtherTP } from 'other-lib'; +const App = () => ( + <> + {children} + {children} + +); +`, + tsxTestOptions, + ); + }); + + it('produces the same result when run twice (idempotent)', () => { + const AFTER_FIRST_PASS = ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`; + + runInlineTest( + transform, + {}, + { + path: 'idempotent-pass1.tsx', + source: ` +import { ThemeProvider } from '@coinbase/cds-web'; +const App = () => {children}; +`, + }, + AFTER_FIRST_PASS, + tsxTestOptions, + ); + + runInlineTest( + transform, + {}, + { path: 'idempotent-pass2.tsx', source: AFTER_FIRST_PASS }, + '', + tsxTestOptions, + ); + }); +}); diff --git a/packages/migrator/src/transforms/v9/button-variant-values.ts b/packages/migrator/src/transforms/v9/button-variant-values.ts new file mode 100644 index 0000000000..b8ad36a333 --- /dev/null +++ b/packages/migrator/src/transforms/v9/button-variant-values.ts @@ -0,0 +1,125 @@ +/** + * Button Variant Values Transform (v8 → v9) + * + * Remaps Button/IconButton `variant` prop values to reflect v9 naming: + * - "tertiary" → "inverse" (old tertiary used bgInverse; v9 gives tertiary new semantics) + * - "foregroundMuted" → "secondary" (foregroundMuted deprecated per design) + * + * Only targets components imported from `@/cds-web/buttons`, deeper paths under + * `cds-web/buttons/…` (e.g. `buttons/Button`), and the same for `cds-mobile`. v8 does not + * export Button/IconButton from the package root (`@/cds-web` / `@/cds-mobile`), + * so those imports are ignored. + * Use CLI `-ps` / `--package-scope` to limit to one scope, or omit to match every scope. + * Adds TODO comments for dynamic variant expressions that need manual review. + */ +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { applyImportMappings, getImportMappingsFromOptions } from '../../utils/import-mapping'; +import { getPackageScopeFromOptions, scopedModulePathRegexPrefix } from '../../utils/package-scope'; +import { addTodoComment, hasMigrationTodo, transformLogger } from '../../utils/transform-utils'; + +const VARIANT_MAP: Record = { + tertiary: 'inverse', + foregroundMuted: 'secondary', +}; +const TRANSFORM_NAME = 'button-variant-values'; + +/** v8 buttons live under `…/buttons` (barrel or deep imports); package root has no Button API. */ +function buildCdsWebOrMobileButtonsImportRe(packageScope: string | undefined): RegExp { + const prefix = scopedModulePathRegexPrefix(packageScope); + return new RegExp(`${prefix}/(cds-web|cds-mobile)/buttons(?:/[^/]+)*$`); +} + +const TARGET_COMPONENTS = ['Button', 'IconButton']; + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const rewrites = getImportMappingsFromOptions(options); + const cdsPackageRe = buildCdsWebOrMobileButtonsImportRe(packageScope); + + const cdsComponentLocalNames = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path) => { + const src = path.value.source; + if (!j.StringLiteral.check(src)) return false; + return cdsPackageRe.test(applyImportMappings(src.value, rewrites)); + }) + .forEach((path) => { + path.value.specifiers?.forEach((specifier) => { + if ( + j.ImportSpecifier.check(specifier) && + TARGET_COMPONENTS.includes(specifier.imported.name) + ) { + cdsComponentLocalNames.add(specifier.local?.name ?? specifier.imported.name); + } + }); + }); + + if (cdsComponentLocalNames.size === 0) { + return null; + } + + let hasChanges = false; + + root + .find(j.JSXElement) + .filter((path) => { + const name = path.value.openingElement.name; + return j.JSXIdentifier.check(name) && cdsComponentLocalNames.has(name.name); + }) + .forEach((path) => { + const variantAttr = path.value.openingElement.attributes?.find( + (attr) => + j.JSXAttribute.check(attr) && + j.JSXIdentifier.check(attr.name) && + attr.name.name === 'variant', + ); + + if (!variantAttr || !j.JSXAttribute.check(variantAttr)) return; + + const value = variantAttr.value; + + if (j.StringLiteral.check(value)) { + const oldVariant = value.value; + const newVariant = VARIANT_MAP[oldVariant]; + if (newVariant) { + value.value = newVariant; + hasChanges = true; + + transformLogger.success( + `Updated variant: ${oldVariant} → ${newVariant}`, + file.path, + path.value.loc?.start.line, + ); + } + } else if (j.JSXExpressionContainer.check(value)) { + if (!hasMigrationTodo(path)) { + addTodoComment( + j, + path, + TRANSFORM_NAME, + 'Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating.', + ); + + transformLogger.warn( + 'Dynamic variant expression requires manual review', + file.path, + path.value.loc?.start.line, + ); + + hasChanges = true; + } + } + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-interactable-css-vars-css.ts b/packages/migrator/src/transforms/v9/migrate-interactable-css-vars-css.ts new file mode 100644 index 0000000000..64185ff76f --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-interactable-css-vars-css.ts @@ -0,0 +1,148 @@ +/** + * Interactable CSS variable rename — CSS/SCSS/HTML edition (v8 → v9, web only) + * + * Companion to `migrate-interactable-css-vars.ts` which handles JS/TS files. + * This script renames the same 11 CSS custom properties in stylesheet and HTML + * files that jscodeshift cannot parse: + * + * --interactable-border-radius → --inter-borderRadius + * --interactable-background → --inter-bg + * --interactable-border-color → --inter-borderColor + * --interactable-pressed-background → --inter-press-bg + * --interactable-pressed-border-color → --inter-press-borderColor + * --interactable-pressed-opacity → --inter-press-opacity + * --interactable-hovered-background → --inter-hover-bg + * --interactable-hovered-border-color → --inter-hover-borderColor + * --interactable-hovered-opacity → --inter-hover-opacity + * --interactable-disabled-background → --inter-disable-bg + * --interactable-disabled-border-color → --inter-disable-borderColor + * + * Handles: .css .scss .html + * Not handled: JS/TS files — use the jscodeshift companion transform instead. + * + * Usage (via cds-migrate CLI): + * cds-migrate --preset v8-to-v9-web + * + * Usage (direct): + * node migrate-interactable-css-vars-css.js [--dry] + * + * Idempotent: a second run is a no-op once all names have been updated. + */ +import fg from 'fast-glob'; +import fs from 'fs'; +import path from 'path'; + +export const VAR_RENAMES: ReadonlyArray = [ + ['--interactable-border-radius', '--inter-borderRadius'], + ['--interactable-background', '--inter-bg'], + ['--interactable-border-color', '--inter-borderColor'], + ['--interactable-pressed-background', '--inter-press-bg'], + ['--interactable-pressed-border-color', '--inter-press-borderColor'], + ['--interactable-pressed-opacity', '--inter-press-opacity'], + ['--interactable-hovered-background', '--inter-hover-bg'], + ['--interactable-hovered-border-color', '--inter-hover-borderColor'], + ['--interactable-hovered-opacity', '--inter-hover-opacity'], + ['--interactable-disabled-background', '--inter-disable-bg'], + ['--interactable-disabled-border-color', '--inter-disable-borderColor'], +]; + +export const FILE_PATTERNS = ['**/*.css', '**/*.scss', '**/*.html']; + +/** + * Fallback ignore patterns used when the script is invoked directly (not via + * the cds-migrate CLI). When invoked through the CLI runner, ignore patterns + * are forwarded as `--ignore-pattern=` args and take precedence over this list. + */ +export const DEFAULT_IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/.next/**', + '**/dist/**', + '**/build/**', +]; + +/** + * Parses `--ignore-pattern=` entries from a CLI args array. + * Uses the same flag name as jscodeshift for consistency. + * Falls back to `DEFAULT_IGNORE_PATTERNS` when none are provided, so the + * script behaves sensibly when run directly without the CLI runner. + */ +export function parseIgnorePatterns(args: string[]): string[] { + const fromArgs = args + .filter((a) => a.startsWith('--ignore-pattern=')) + .map((a) => a.slice('--ignore-pattern='.length)); + return fromArgs.length > 0 ? fromArgs : DEFAULT_IGNORE_PATTERNS; +} + +/** Returns the content with all old CSS var names replaced, or the original string if nothing changed. */ +export function applyRenames(content: string): string { + let result = content; + for (const [oldVar, newVar] of VAR_RENAMES) { + result = result.replaceAll(oldVar, newVar); + } + return result; +} + +export type ProcessResult = { processed: number; changed: number }; + +/** + * Finds all CSS/SCSS/HTML files under `targetPath`, applies renames, and + * writes changes back to disk (unless `dryRun` is true). + * + * `ignorePatterns` defaults to `DEFAULT_IGNORE_PATTERNS` but is overridden + * by the CLI runner, which forwards its own canonical ignore list so both + * jscodeshift and script transforms exclude the same directories. + */ +export async function processDirectory( + targetPath: string, + dryRun: boolean, + ignorePatterns: string[] = DEFAULT_IGNORE_PATTERNS, +): Promise { + const files = await fg(FILE_PATTERNS, { + cwd: targetPath, + absolute: true, + ignore: ignorePatterns, + }); + + let processed = 0; + let changed = 0; + + for (const filePath of files) { + const original = fs.readFileSync(filePath, 'utf8'); + const updated = applyRenames(original); + + if (updated !== original) { + if (!dryRun) { + fs.writeFileSync(filePath, updated, 'utf8'); + } + changed++; + console.log(`✓ ${dryRun ? '[dry] ' : ''}${path.relative(targetPath, filePath)}`); + } + + processed++; + } + + return { processed, changed }; +} + +// CLI entry point — only executes when this file is run directly via `node`. +if (require.main === module) { + const [, , targetPath, ...rest] = process.argv; + const dryRun = rest.includes('--dry'); + const ignorePatterns = parseIgnorePatterns(rest); + + if (!targetPath) { + console.error( + 'Usage: migrate-interactable-css-vars-css [--dry] [--ignore-pattern=...]', + ); + process.exit(1); + } + + processDirectory(targetPath, dryRun, ignorePatterns) + .then(({ processed, changed }) => { + console.log(`\nProcessed ${processed} files, updated ${changed}`); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/packages/migrator/src/transforms/v9/migrate-interactable-css-vars.ts b/packages/migrator/src/transforms/v9/migrate-interactable-css-vars.ts new file mode 100644 index 0000000000..9dfcc528bb --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-interactable-css-vars.ts @@ -0,0 +1,93 @@ +/** + * Interactable CSS variable rename migration (v8 → v9, web only) + * + * In v9, the CSS custom properties exposed by the Pressable / interactable + * system were shortened and switched from all-kebab-case to a camelCase + * property suffix: + * + * --interactable-border-radius → --inter-borderRadius + * --interactable-background → --inter-bg + * --interactable-border-color → --inter-borderColor + * --interactable-pressed-background → --inter-press-bg + * --interactable-pressed-border-color → --inter-press-borderColor + * --interactable-pressed-opacity → --inter-press-opacity + * --interactable-hovered-background → --inter-hover-bg + * --interactable-hovered-border-color → --inter-hover-borderColor + * --interactable-hovered-opacity → --inter-hover-opacity + * --interactable-disabled-background → --inter-disable-bg + * --interactable-disabled-border-color → --inter-disable-borderColor + * + * Handles JS/TS files only: + * - String literals used as style object keys or values (e.g. `var(--interactable-background)`) + * - Template literal quasis (static string segments) + * + * Not handled (require a CSS/text-based migration tool): + * - CSS / SCSS / Less files + * - HTML inline style attributes + * + * Idempotent: a second run is a no-op once all names have been updated. + */ +import type { API, FileInfo } from 'jscodeshift'; + +import { transformLogger } from '../../utils/transform-utils'; + +const VAR_RENAMES: ReadonlyArray = [ + ['--interactable-border-radius', '--inter-borderRadius'], + ['--interactable-background', '--inter-bg'], + ['--interactable-border-color', '--inter-borderColor'], + ['--interactable-pressed-background', '--inter-press-bg'], + ['--interactable-pressed-border-color', '--inter-press-borderColor'], + ['--interactable-pressed-opacity', '--inter-press-opacity'], + ['--interactable-hovered-background', '--inter-hover-bg'], + ['--interactable-hovered-border-color', '--inter-hover-borderColor'], + ['--interactable-hovered-opacity', '--inter-hover-opacity'], + ['--interactable-disabled-background', '--inter-disable-bg'], + ['--interactable-disabled-border-color', '--inter-disable-borderColor'], +]; + +/** Returns the string with all old CSS var names replaced, or null if nothing changed. */ +function applyRenames(value: string): string | null { + let result = value; + for (const [oldVar, newVar] of VAR_RENAMES) { + result = result.replaceAll(oldVar, newVar); + } + return result !== value ? result : null; +} + +export default function transformer(file: FileInfo, api: API) { + const j = api.jscodeshift; + const root = j(file.source); + + let hasChanges = false; + + root.find(j.StringLiteral).forEach((path) => { + const next = applyRenames(path.value.value); + if (next === null) return; + transformLogger.success( + `Renamed CSS var in string literal: ${path.value.value} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + path.value.value = next; + hasChanges = true; + }); + + root.find(j.TemplateElement).forEach((path) => { + const raw = applyRenames(path.value.value.raw); + if (raw === null) return; + const cooked = + path.value.value.cooked !== null && path.value.value.cooked !== undefined + ? (applyRenames(path.value.value.cooked) ?? path.value.value.cooked) + : path.value.value.cooked; + transformLogger.success( + `Renamed CSS var in template literal: ${path.value.value.raw} → ${raw}`, + file.path, + path.value.loc?.start.line, + ); + path.value.value = { raw, cooked }; + hasChanges = true; + }); + + if (!hasChanges) return null; + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-layout-types-mobile.ts b/packages/migrator/src/transforms/v9/migrate-layout-types-mobile.ts new file mode 100644 index 0000000000..c97e304ce3 --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-layout-types-mobile.ts @@ -0,0 +1,188 @@ +/** + * Mobile: migrate deprecated layout types from `@/cds-common` to CDS Mobile (same scope) + React Native. + * + * - `PositionStyles` → `import type { PositionStyles } from '@/cds-mobile/styles/styleProps'`. + * - `DimensionValue` → `import type { DimensionValue } from 'react-native'` (RN’s `DimensionValue`; + * review usages that relied on CDS-common’s distinct union). + * + * Matches any npm scope on `cds-common`. Use CLI `-ps` / `--package-scope` to limit to one scope, + * like `migrate-use-merge-refs`. + * + * Idempotent: second run is a no-op when common no longer supplies these imports. + * + * Not handled: `export { ... } from 'cds-common'`, `require()`, dynamic imports, versioned entrypoints + * (e.g. `@scope/cds-common/v7`) — migrate by hand or extend the path matcher below. + */ + +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { ensureImportSpecifiers } from '../../utils/ensure-import-specifiers'; +import { getPackageScopeFromOptions, scopedModulePathRegexPrefix } from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +const REACT_NATIVE_MODULE = 'react-native'; + +const MIGRATED_TYPE_NAMES = new Set(['PositionStyles', 'DimensionValue']); + +function buildCdsCommonLayoutSourceRe(packageScope: string | undefined): RegExp { + const prefix = scopedModulePathRegexPrefix(packageScope); + return new RegExp( + `${prefix}/(?:cds-common|cds-common/types|cds-common/types/BoxBaseProps|cds-common/types/DimensionStyles)$`, + ); +} + +function isCommonLayoutTypeSource( + source: string | undefined | null, + packageScope?: string | undefined, +): boolean { + if (typeof source !== 'string') { + return false; + } + return buildCdsCommonLayoutSourceRe(packageScope).test(source); +} + +function cdsPackageScopeFromCommonImport(fromImportSource: string): string | null { + const m = fromImportSource.match(/^(@[^/]+)\/cds-common(?:\/|$)/); + return m ? m[1] : null; +} + +function mobileStylePropsModule(fromImportSource: string): string { + const scope = cdsPackageScopeFromCommonImport(fromImportSource); + return scope ? `${scope}/cds-mobile/styles/styleProps` : '@coinbase/cds-mobile/styles/styleProps'; +} + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + + const alreadyImportsRnDimensionValue = + root + .find(j.ImportDeclaration, { source: { value: REACT_NATIVE_MODULE } }) + .filter((path) => + (path.value.specifiers ?? []).some( + (spec) => + j.ImportSpecifier.check(spec) && + j.Identifier.check(spec.imported) && + spec.imported.name === 'DimensionValue', + ), + ).length > 0; + + const hasLocalDimensionValueType = + root.find(j.TSTypeAliasDeclaration, { id: { name: 'DimensionValue' } }).length > 0; + + let sampleCdsCommonSource: string | null = null; + + let needsMigration = false; + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (!j.StringLiteral.check(src) || !isCommonLayoutTypeSource(src.value, packageScope)) { + return; + } + for (const spec of path.value.specifiers ?? []) { + if ( + j.ImportSpecifier.check(spec) && + j.Identifier.check(spec.imported) && + MIGRATED_TYPE_NAMES.has(spec.imported.name) + ) { + needsMigration = true; + } + } + }); + + if (!needsMigration) { + return null; + } + + let stripPositionStyles = false; + let stripDimensionValue = false; + let mobileTarget: string | null = null; + + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (!j.StringLiteral.check(src) || !isCommonLayoutTypeSource(src.value, packageScope)) { + return; + } + + if (!sampleCdsCommonSource) { + sampleCdsCommonSource = src.value; + } + + const specs = path.value.specifiers ?? []; + const next: typeof specs = []; + for (const spec of specs) { + if (!j.ImportSpecifier.check(spec) || !j.Identifier.check(spec.imported)) { + next.push(spec); + continue; + } + const name = spec.imported.name; + if (name === 'PositionStyles') { + stripPositionStyles = true; + if (!mobileTarget) { + mobileTarget = mobileStylePropsModule(src.value); + } + continue; + } + if (name === 'DimensionValue') { + if (hasLocalDimensionValueType) { + next.push(spec); + transformLogger.warn( + 'Skipped removing DimensionValue import: a local type DimensionValue already exists', + file.path, + ); + } else { + stripDimensionValue = true; + } + continue; + } + next.push(spec); + } + path.value.specifiers = next; + }); + + root.find(j.ImportDeclaration).forEach((path) => { + if (path.value.specifiers?.length === 0) { + j(path).remove(); + } + }); + + if (!stripPositionStyles && !stripDimensionValue) { + return null; + } + + let fileChanged = false; + + if (stripPositionStyles) { + const target = + mobileTarget ?? mobileStylePropsModule(sampleCdsCommonSource ?? '@coinbase/cds-common'); + if (ensureImportSpecifiers(j, root, target, ['PositionStyles'])) { + fileChanged = true; + transformLogger.success(`Ensured type import PositionStyles from ${target}`, file.path); + } + } + + if (stripDimensionValue && !hasLocalDimensionValueType && !alreadyImportsRnDimensionValue) { + if (ensureImportSpecifiers(j, root, REACT_NATIVE_MODULE, ['DimensionValue'])) { + fileChanged = true; + transformLogger.success('Ensured type import DimensionValue from react-native', file.path); + } + } else if (stripDimensionValue && alreadyImportsRnDimensionValue) { + fileChanged = true; + transformLogger.success('DimensionValue already imported from react-native', file.path); + } + + if (stripPositionStyles || stripDimensionValue) { + fileChanged = true; + transformLogger.success( + 'Removed PositionStyles and/or DimensionValue from cds-common import(s)', + file.path, + ); + } + + if (!fileChanged) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-layout-types-web.ts b/packages/migrator/src/transforms/v9/migrate-layout-types-web.ts new file mode 100644 index 0000000000..e9878d3139 --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-layout-types-web.ts @@ -0,0 +1,199 @@ +/** + * Web: migrate deprecated layout types from `@/cds-common` to CDS Web (same scope). + * + * - `PositionStyles` → `import type { PositionStyles } from '@/cds-web/styles/styleProps'`. + * - `DimensionValue` → remove from common import; add local + * `type DimensionValue = string | number | 'auto'` (matches former common `DimensionValue` union). + * + * Matches any npm scope on `cds-common`. Use CLI `-ps` / `--package-scope` to limit to one scope, + * like `migrate-use-merge-refs`. + * + * Idempotent: second run is a no-op when common no longer exports these imports. + * + * Not handled: `export { ... } from 'cds-common'`, `require()`, dynamic imports, versioned entrypoints + * (e.g. `@scope/cds-common/v7`) — migrate by hand or extend the path matcher below. + */ + +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { ensureImportSpecifiers } from '../../utils/ensure-import-specifiers'; +import { applyImportMappings, getImportMappingsFromOptions } from '../../utils/import-mapping'; +import { getPackageScopeFromOptions, scopedModulePathRegexPrefix } from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +const MIGRATED_TYPE_NAMES = new Set(['PositionStyles', 'DimensionValue']); + +function buildCdsCommonLayoutSourceRe(packageScope: string | undefined): RegExp { + const prefix = scopedModulePathRegexPrefix(packageScope); + return new RegExp( + `${prefix}/(?:cds-common|cds-common/types|cds-common/types/BoxBaseProps|cds-common/types/DimensionStyles)$`, + ); +} + +function isCommonLayoutTypeSource( + source: string | undefined | null, + packageScope?: string | undefined, +): boolean { + if (typeof source !== 'string') { + return false; + } + return buildCdsCommonLayoutSourceRe(packageScope).test(source); +} + +function cdsPackageScopeFromCommonImport(fromImportSource: string): string | null { + const m = fromImportSource.match(/^(@[^/]+)\/cds-common(?:\/|$)/); + return m ? m[1] : null; +} + +function webStylePropsModule(fromImportSource: string): string { + const scope = cdsPackageScopeFromCommonImport(fromImportSource); + return scope ? `${scope}/cds-web/styles/styleProps` : '@coinbase/cds-web/styles/styleProps'; +} + +function parseDimensionValueAlias(j: API['jscodeshift']) { + const parsed = j(`type DimensionValue = string | number | 'auto';`); + return parsed.paths()[0].value.program.body[0]; +} + +function lastImportIndex(j: API['jscodeshift'], body: unknown[]): number { + let idx = -1; + for (let i = 0; i < body.length; i++) { + if (j.ImportDeclaration.check(body[i])) { + idx = i; + } + } + return idx; +} + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const rewrites = getImportMappingsFromOptions(options); + + const hasLocalDimensionValueType = + root.find(j.TSTypeAliasDeclaration, { id: { name: 'DimensionValue' } }).length > 0; + + let needsMigration = false; + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if ( + !j.StringLiteral.check(src) || + !isCommonLayoutTypeSource(applyImportMappings(src.value, rewrites), packageScope) + ) { + return; + } + for (const spec of path.value.specifiers ?? []) { + if ( + j.ImportSpecifier.check(spec) && + j.Identifier.check(spec.imported) && + MIGRATED_TYPE_NAMES.has(spec.imported.name) + ) { + needsMigration = true; + } + } + }); + + if (!needsMigration) { + return null; + } + + let stripPositionStyles = false; + let stripDimensionValue = false; + let stylePropsTarget: string | null = null; + + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if ( + !j.StringLiteral.check(src) || + !isCommonLayoutTypeSource(applyImportMappings(src.value, rewrites), packageScope) + ) { + return; + } + + const specs = path.value.specifiers ?? []; + const next: typeof specs = []; + for (const spec of specs) { + if (!j.ImportSpecifier.check(spec) || !j.Identifier.check(spec.imported)) { + next.push(spec); + continue; + } + const name = spec.imported.name; + if (name === 'PositionStyles') { + stripPositionStyles = true; + if (!stylePropsTarget) { + stylePropsTarget = webStylePropsModule(src.value); + } + continue; + } + if (name === 'DimensionValue') { + if (hasLocalDimensionValueType) { + next.push(spec); + transformLogger.warn( + 'Skipped removing DimensionValue import: a local type DimensionValue already exists', + file.path, + ); + } else { + stripDimensionValue = true; + } + continue; + } + next.push(spec); + } + path.value.specifiers = next; + }); + + root.find(j.ImportDeclaration).forEach((path) => { + if (path.value.specifiers?.length === 0) { + j(path).remove(); + } + }); + + if (!stripPositionStyles && !stripDimensionValue) { + return null; + } + + let fileChanged = false; + + const typeNamesToImport: string[] = []; + if (stripPositionStyles) { + typeNamesToImport.push('PositionStyles'); + } + + if (typeNamesToImport.length > 0 && stylePropsTarget) { + if (ensureImportSpecifiers(j, root, stylePropsTarget, typeNamesToImport)) { + fileChanged = true; + transformLogger.success( + `Ensured type import(s) from ${stylePropsTarget}: ${typeNamesToImport.join(', ')}`, + file.path, + ); + } + } + + if (stripDimensionValue && !hasLocalDimensionValueType) { + const body = root.get().value.program.body as object[]; + const insertAt = lastImportIndex(j, body) + 1; + const aliasNode = parseDimensionValueAlias(j); + body.splice(insertAt, 0, aliasNode); + fileChanged = true; + transformLogger.success( + "Added local type alias DimensionValue (= string | number | 'auto')", + file.path, + ); + } + + if (stripPositionStyles || stripDimensionValue) { + fileChanged = true; + transformLogger.success( + 'Removed PositionStyles and/or DimensionValue from cds-common import(s)', + file.path, + ); + } + + if (!fileChanged) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts b/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts new file mode 100644 index 0000000000..a77d681dfe --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts @@ -0,0 +1,413 @@ +/** + * Migrate `useMergeRefs` from the deprecated hooks entry to `mergeRefs` on `utils/mergeRefs`. + * + * Goal: use `…/utils/mergeRefs` and the `mergeRefs` binding (the deprecated `useMergeRefs` export is + * identical at runtime to `mergeRefs`). Matches `@/cds-common/…`; use CLI `-ps` / `--package-scope` + * to limit to one scope, or omit to match every scope. + * + * Cases handled: + * - A: `import { useMergeRefs } from '…hooks/useMergeRefs'` → `import { mergeRefs } from '…utils/mergeRefs'`. + * - B: `import { useMergeRefs as x } from '…'` → `import { mergeRefs as x } from '…utils/mergeRefs'`. + * - C: `export { useMergeRefs } from '…hooks/useMergeRefs'` → path + `export { mergeRefs } from '…utils/mergeRefs'`. + * - D: Any string literal exactly equal to the deprecated module path → new path. + * - E: Call sites and other references `useMergeRefs` → `mergeRefs` when they refer to CDS (see below). + * - F: After rewrites, multiple `import … from '…utils/mergeRefs'` → merged; named specifiers deduped. + * + * Reference rename safety: + * - Global `useMergeRefs` → `mergeRefs` renames run only if this file touched a CDS mergeRefs + * module (deprecated path, mock string, or `useMergeRefs` in a CDS import/re-export). Files that + * only import `useMergeRefs` from other packages are left unchanged. + * - Skips non-computed object literal keys (`{ useMergeRefs: 1 }`), TS property keys, class method + * names, and non-computed member expression properties (`obj.useMergeRefs`). + * - Renames object shorthand `{ useMergeRefs }` (value/key are the same binding). + * + * Not handled: + * - Relative paths (e.g. `../hooks/useMergeRefs`). + * - Dynamic `import()` / `require()` for this module. + * + * Idempotency: second run leaves the file unchanged. + * + * @see packages/common/src/hooks/useMergeRefs.ts — re-exports from ../utils/mergeRefs + */ + +import type { API, ASTPath, FileInfo, Identifier, Options } from 'jscodeshift'; + +import { applyImportMappings, getImportMappingsFromOptions } from '../../utils/import-mapping'; +import { + escapeRegExp, + getPackageScopeFromOptions, + scopedModulePathRegexPrefix, +} from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +/** Regex source for `@scope/cds-common` (capture) — merge-refs module paths only. */ +function cdsCommonPackageRootRegexSource(packageScope: string | undefined): string { + if (packageScope) { + return `${escapeRegExp(packageScope)}/cds-common`; + } + return '@[^/]+/cds-common'; +} + +/** `…/hooks/useMergeRefs` → `…/utils/mergeRefs` under the same `@scope/cds-common`. */ +function rewriteCdsCommonHooksUseMergeRefsToUtils( + modulePath: string, + packageScope: string | undefined, +): string | null { + const base = cdsCommonPackageRootRegexSource(packageScope); + const m = modulePath.match(new RegExp(`^(${base})/hooks/useMergeRefs$`)); + return m ? `${m[1]}/utils/mergeRefs` : null; +} + +function isCdsMergeRefsModuleSource( + j: API['jscodeshift'], + source: unknown, + moduleRe: RegExp, +): source is { value: string } { + return j.StringLiteral.check(source) && moduleRe.test(source.value); +} + +/** + * Unique `import … from '@/cds-common/utils/mergeRefs'` sources in the file (for consolidation). + */ +function collectCdsCommonMergeRefsUtilsModulePaths( + j: API['jscodeshift'], + root: ReturnType, + utilsModuleRe: RegExp, +): string[] { + const seen = new Set(); + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (j.StringLiteral.check(src) && utilsModuleRe.test(src.value)) { + seen.add(src.value); + } + }); + return [...seen]; +} + +/** + * `import { useMergeRefs }` / `as x` → `mergeRefs` / `as x` when from CDS mergeRefs modules. + */ +function renameUseMergeRefsInImportSpecifiers( + j: API['jscodeshift'], + root: ReturnType, + moduleRe: RegExp, +): boolean { + let changed = false; + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (!isCdsMergeRefsModuleSource(j, src, moduleRe)) { + return; + } + path.value.specifiers?.forEach((spec) => { + if (!j.ImportSpecifier.check(spec)) { + return; + } + if (j.Identifier.check(spec.imported) && spec.imported.name === 'useMergeRefs') { + spec.imported.name = 'mergeRefs'; + changed = true; + } + if (spec.local && j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + changed = true; + } + }); + }); + return changed; +} + +/** + * `export { useMergeRefs … } from '@coinbase/…/mergeRefs'` + */ +function renameUseMergeRefsInExportSpecifiersFromCds( + j: API['jscodeshift'], + root: ReturnType, + moduleRe: RegExp, +): boolean { + let changed = false; + root.find(j.ExportNamedDeclaration).forEach((path) => { + const src = path.value.source; + if (!src || !isCdsMergeRefsModuleSource(j, src, moduleRe)) { + return; + } + path.value.specifiers?.forEach((spec) => { + if (!j.ExportSpecifier.check(spec)) { + return; + } + if (j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + changed = true; + } + if (j.Identifier.check(spec.exported) && spec.exported.name === 'useMergeRefs') { + spec.exported.name = 'mergeRefs'; + changed = true; + } + }); + }); + return changed; +} + +function isObjectLiteralKey( + j: API['jscodeshift'], + parent: unknown, + idPath: ASTPath, +): boolean { + if (!parent || typeof parent !== 'object') { + return false; + } + + const node = parent as { type?: string; key?: unknown; computed?: boolean; shorthand?: boolean }; + if (node.type !== 'ObjectProperty' && node.type !== 'Property') { + return false; + } + if (node.key !== idPath.value || node.computed) { + return false; + } + if (node.shorthand) { + return false; + } + return true; +} + +/** + * Remaining `useMergeRefs` identifiers → `mergeRefs` (call sites, `export { useMergeRefs }` without from, etc.). + * Only runs when this file participated in a CDS mergeRefs module migration (avoids renaming unrelated bindings). + */ +function renameRemainingUseMergeRefsIdentifiers( + j: API['jscodeshift'], + root: ReturnType, + enabled: boolean, +): boolean { + if (!enabled) { + return false; + } + + let changed = false; + + root.find(j.Identifier, { name: 'useMergeRefs' }).forEach((path: ASTPath) => { + const parent = path.parent?.node; + + if (j.ImportSpecifier.check(parent)) { + return; + } + + if (j.ExportSpecifier.check(parent)) { + const spec = parent; + let touched = false; + if (j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + touched = true; + } + if (j.Identifier.check(spec.exported) && spec.exported.name === 'useMergeRefs') { + spec.exported.name = 'mergeRefs'; + touched = true; + } + if (touched) { + changed = true; + } + return; + } + + if (isObjectLiteralKey(j, parent, path)) { + return; + } + + if (j.TSPropertySignature?.check(parent) && parent.key === path.value) { + return; + } + + if (j.MemberExpression.check(parent) && parent.property === path.value && !parent.computed) { + return; + } + + if (j.MethodDefinition.check(parent) && parent.key === path.value) { + return; + } + + path.value.name = 'mergeRefs'; + changed = true; + }); + + root.find(j.JSXIdentifier, { name: 'useMergeRefs' }).forEach((path) => { + path.value.name = 'mergeRefs'; + changed = true; + }); + + return changed; +} + +/** + * Merge multiple `import … from targetModule` into a single declaration; dedupe named imports. + */ +function consolidateImportsFromMergeRefsModule( + j: API['jscodeshift'], + root: ReturnType, + targetModule: string, +) { + const declarations = root + .find(j.ImportDeclaration, { source: { value: targetModule } }) + .filter((path) => (path.value.specifiers?.length ?? 0) > 0); + + if (declarations.length <= 1) { + return; + } + + const paths = declarations.paths(); + const first = paths[0]; + const mergedNamed = new Map(); + let defaultImport: string | null = null; + let namespaceImport: string | null = null; + + for (const path of paths) { + const specifiers = path.value.specifiers ?? []; + for (const spec of specifiers) { + if (j.ImportDefaultSpecifier.check(spec)) { + const local = spec.local?.name; + if (local) { + defaultImport = local; + } + } else if (j.ImportNamespaceSpecifier.check(spec)) { + const local = spec.local?.name; + if (local) { + namespaceImport = local; + } + } else if (j.ImportSpecifier.check(spec)) { + const imported = j.Identifier.check(spec.imported) + ? spec.imported.name + : String((spec.imported as { value?: string }).value); + const local = spec.local?.name ?? imported; + if (!mergedNamed.has(imported)) { + mergedNamed.set(imported, { imported, local }); + } + } + } + } + + const newSpecifiers: typeof first.value.specifiers = []; + + if (namespaceImport) { + newSpecifiers.push(j.importNamespaceSpecifier(j.identifier(namespaceImport))); + } else if (defaultImport) { + newSpecifiers.push(j.importDefaultSpecifier(j.identifier(defaultImport))); + } + + const sortedNamed = [...mergedNamed.values()].sort((a, b) => + a.imported.localeCompare(b.imported), + ); + for (const { imported, local } of sortedNamed) { + newSpecifiers.push( + j.importSpecifier(j.identifier(imported), local === imported ? null : j.identifier(local)), + ); + } + + first.value.specifiers = newSpecifiers; + + for (let i = 1; i < paths.length; i++) { + j(paths[i]).remove(); + } +} + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const rewrites = getImportMappingsFromOptions(options); + const scopePrefix = scopedModulePathRegexPrefix(packageScope); + const mergeRefsModuleRe = new RegExp( + `${scopePrefix}/cds-common/(hooks\\/useMergeRefs|utils\\/mergeRefs)$`, + ); + const mergeRefsUtilsModuleRe = new RegExp(`${scopePrefix}/cds-common/utils/mergeRefs$`); + + let hasChanges = false; + let cdsMergeRefsMigration = false; + + root.find(j.ImportDeclaration).forEach((path) => { + if (path.value.source && j.StringLiteral.check(path.value.source)) { + const resolvedSource = applyImportMappings(path.value.source.value, rewrites); + const next = rewriteCdsCommonHooksUseMergeRefsToUtils(resolvedSource, packageScope); + if (next) { + const prev = path.value.source.value; + path.value.source = j.stringLiteral(next); + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated import: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + } + }); + + root.find(j.ExportNamedDeclaration).forEach((path) => { + const src = path.value.source; + if (src && j.StringLiteral.check(src)) { + const next = rewriteCdsCommonHooksUseMergeRefsToUtils( + applyImportMappings(src.value, rewrites), + packageScope, + ); + if (next) { + const prev = src.value; + path.value.source = j.stringLiteral(next); + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated export from: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + } + }); + + root.find(j.StringLiteral).forEach((path) => { + const next = rewriteCdsCommonHooksUseMergeRefsToUtils( + applyImportMappings(path.value.value, rewrites), + packageScope, + ); + if (next) { + const prev = path.value.value; + path.value.value = next; + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated module path string: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + }); + + if (renameUseMergeRefsInImportSpecifiers(j, root, mergeRefsModuleRe)) { + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success(`Renamed import useMergeRefs → mergeRefs`, file.path); + } + + if (renameUseMergeRefsInExportSpecifiersFromCds(j, root, mergeRefsModuleRe)) { + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success(`Renamed re-export useMergeRefs → mergeRefs`, file.path); + } + + if (renameRemainingUseMergeRefsIdentifiers(j, root, cdsMergeRefsMigration)) { + hasChanges = true; + transformLogger.success(`Renamed remaining useMergeRefs → mergeRefs`, file.path); + } + + if (hasChanges) { + for (const targetModule of collectCdsCommonMergeRefsUtilsModulePaths( + j, + root, + mergeRefsUtilsModuleRe, + )) { + consolidateImportsFromMergeRefsModule(j, root, targetModule); + } + } + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-visualization-imports.ts b/packages/migrator/src/transforms/v9/migrate-visualization-imports.ts new file mode 100644 index 0000000000..8bcde8a248 --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-visualization-imports.ts @@ -0,0 +1,138 @@ +/** + * Visualization package import migration (v8 → v9, web + mobile) + * + * Rewrites import (and re-export) paths from the deprecated standalone visualization + * packages to the sub-paths now shipped directly inside `cds-web` and `cds-mobile`: + * + * @/cds-web-visualization → @/cds-web/visualizations + * @/cds-web-visualization/chart → @/cds-web/visualizations/chart + * @/cds-web-visualization/sparkline → @/cds-web/visualizations/sparkline + * @/cds-web-visualization/ → @/cds-web/visualizations/ + * + * @/cds-mobile-visualization → @/cds-mobile/visualizations + * @/cds-mobile-visualization/chart → @/cds-mobile/visualizations/chart + * @/cds-mobile-visualization/sparkline → @/cds-mobile/visualizations/sparkline + * @/cds-mobile-visualization/ → @/cds-mobile/visualizations/ + * + * Handles: + * - `import { … } from '…'` (ImportDeclaration) + * - `export { … } from '…'` (ExportNamedDeclaration with source) + * - `export * from '…'` (ExportAllDeclaration) + * + * Not handled: + * - `require('…')` calls + * - Dynamic `import('…')` expressions + * + * Use CLI `-ps` / `--packageScope` to restrict rewrites to a single npm scope (e.g. `@coinbase`). + * Idempotent: second run is a no-op once paths have been updated. + */ +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { applyImportMappings, getImportMappingsFromOptions } from '../../utils/import-mapping'; +import { escapeRegExp, getPackageScopeFromOptions } from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +/** + * Builds a regex that both enforces the scope filter (when set) and captures the + * three parts needed for reconstruction: scope, platform (web|mobile), sub-path. + * + * Group 1 — npm scope (e.g. `@coinbase`) + * Group 2 — platform (`web` or `mobile`) + * Group 3 — sub-path (e.g. `/chart`, or undefined when importing the root barrel) + */ +function buildVisualizationRe(packageScope: string | undefined): RegExp { + const scopeCapture = packageScope ? escapeRegExp(packageScope) : '@[^/]+'; + return new RegExp(`^(${scopeCapture})/cds-(web|mobile)-visualization(/.+)?$`); +} + +/** + * Returns the rewritten module path, or `null` if the path does not match (or is + * filtered out by the scope option). + */ +function rewritePath(modulePath: string, vizRe: RegExp): string | null { + const m = modulePath.match(vizRe); + if (!m) return null; + const [, scope, platform, rest] = m; + return `${scope}/cds-${platform}/visualizations${rest ?? ''}`; +} + +function rewriteStringLiteralSource( + j: API['jscodeshift'], + node: { source?: unknown }, + vizRe: RegExp, + filePath: string, + line: number | undefined, + rewrites: ReturnType, +): boolean { + const src = node.source; + if (!j.StringLiteral.check(src)) return false; + const srcLiteral = src as { value: string }; + const next = rewritePath(applyImportMappings(srcLiteral.value, rewrites), vizRe); + if (!next) return false; + transformLogger.success(`Updated import path: ${srcLiteral.value} → ${next}`, filePath, line); + srcLiteral.value = next; + return true; +} + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const rewrites = getImportMappingsFromOptions(options); + const vizRe = buildVisualizationRe(packageScope); + + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((path) => { + if ( + rewriteStringLiteralSource( + j, + path.value, + vizRe, + file.path, + path.value.loc?.start.line, + rewrites, + ) + ) { + hasChanges = true; + } + }); + + root.find(j.ExportNamedDeclaration).forEach((path) => { + if ( + path.value.source && + rewriteStringLiteralSource( + j, + path.value, + vizRe, + file.path, + path.value.loc?.start.line, + rewrites, + ) + ) { + hasChanges = true; + } + }); + + root.find(j.ExportAllDeclaration).forEach((path) => { + if ( + rewriteStringLiteralSource( + j, + path.value, + vizRe, + file.path, + path.value.loc?.start.line, + rewrites, + ) + ) { + hasChanges = true; + } + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/theme-provider-isolated.ts b/packages/migrator/src/transforms/v9/theme-provider-isolated.ts new file mode 100644 index 0000000000..13ff7c4170 --- /dev/null +++ b/packages/migrator/src/transforms/v9/theme-provider-isolated.ts @@ -0,0 +1,103 @@ +/** + * ThemeProvider isolated prop transform (v8 → v9, web only) + * + * In v9, `ThemeProvider` gained an `isolated` prop. When `isolated` is omitted (default `false`), + * the provider calls `diffThemes()` and only injects CSS variable **diffs** relative to the nearest + * parent `ThemeProvider` in the React tree. This optimization breaks any usage where the + * `ThemeProvider` DOM subtree is detached from its React parent (e.g. `createPortal`), because CSS + * variable inheritance stops at the portal boundary. + * + * Setting `isolated={true}` restores the v8 behavior of always injecting the full set of theme CSS + * variables. After running this codemod, teams can selectively remove `isolated` from + * `ThemeProvider` instances that are NOT inside portals to opt into the new diffing optimization. + * + * Matches any import of `ThemeProvider` from `@/cds-web` (root barrel) or any sub-path + * (e.g. `@/cds-web/system`, `@/cds-web/system/ThemeProvider`). Use CLI `-ps` / + * `--packageScope` to restrict to a specific npm scope. + * + * Not handled: + * - `export { ThemeProvider } from '…'` re-exports. + * - `require(…)` imports. + * - Dynamic `import(…)`. + * - `InvertedThemeProvider` (not requested; add a separate codemod if needed). + */ +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { applyImportMappings, getImportMappingsFromOptions } from '../../utils/import-mapping'; +import { getPackageScopeFromOptions, scopedModulePathRegexPrefix } from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +const TARGET_COMPONENT = 'ThemeProvider'; + +function buildCdsWebImportRe(packageScope: string | undefined): RegExp { + const prefix = scopedModulePathRegexPrefix(packageScope); + return new RegExp(`${prefix}/cds-web(/.+)?$`); +} + +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const rewrites = getImportMappingsFromOptions(options); + const cdsWebRe = buildCdsWebImportRe(packageScope); + + const localNames = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path) => { + const src = path.value.source; + if (!j.StringLiteral.check(src)) return false; + return cdsWebRe.test(applyImportMappings(src.value, rewrites)); + }) + .forEach((path) => { + path.value.specifiers?.forEach((specifier) => { + if (j.ImportSpecifier.check(specifier) && specifier.imported.name === TARGET_COMPONENT) { + localNames.add(specifier.local?.name ?? specifier.imported.name); + } + }); + }); + + if (localNames.size === 0) { + return null; + } + + let hasChanges = false; + + root + .find(j.JSXOpeningElement) + .filter((path) => { + const name = path.value.name; + return j.JSXIdentifier.check(name) && localNames.has(name.name); + }) + .forEach((path) => { + const attrs = path.value.attributes ?? []; + + const alreadyHasIsolated = attrs.some( + (attr) => + j.JSXAttribute.check(attr) && + j.JSXIdentifier.check(attr.name) && + attr.name.name === 'isolated', + ); + + if (alreadyHasIsolated) return; + + // JSX boolean shorthand: `isolated` is equivalent to `isolated={true}`. + const isolatedAttr = j.jsxAttribute(j.jsxIdentifier('isolated')); + path.value.attributes = [...attrs, isolatedAttr]; + hasChanges = true; + + transformLogger.success( + `Added isolated prop to ThemeProvider`, + file.path, + path.value.loc?.start.line, + ); + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/types.ts b/packages/migrator/src/types.ts new file mode 100644 index 0000000000..395fb6918a --- /dev/null +++ b/packages/migrator/src/types.ts @@ -0,0 +1,68 @@ +/** + * Types for CDS migration tools + */ + +/** + * A jscodeshift transform — the default. Runs via `npx jscodeshift` against + * JS/TS source files. + */ +export type JscodeshiftTransform = { + type?: 'jscodeshift'; + name: string; + description: string; + /** Path to the transform file relative to the transforms directory (no extension). */ + file: string; + /** + * File extensions to process (comma-separated). + * @default "tsx,ts,jsx,js" + */ + extensions?: string; +}; + +/** + * A Node.js script transform. Runs via `node