Skip to content

chore(yarn): migrate to Yarn 4 + native workspaces#3594

Open
oliverlaz wants to merge 19 commits into
developfrom
chore/yarn-4-workspaces-migration
Open

chore(yarn): migrate to Yarn 4 + native workspaces#3594
oliverlaz wants to merge 19 commits into
developfrom
chore/yarn-4-workspaces-migration

Conversation

@oliverlaz
Copy link
Copy Markdown
Member

@oliverlaz oliverlaz commented May 13, 2026

🎯 Goal

Move us onto Yarn 4 with native workspaces and drop Lerna. The .yarnrc.yml was already half-migrated, but yarnPath still pointed at the v1 binary — this finishes that off.

🛠 Implementation details

Tooling only; nothing in package/src changes. Roughly:

  • .yarn/releases/ swapped to 4.14.1, and all seven yarn.lock files migrated v1 → v8 in place — resolutions preserved, no drift.
  • Single root workspaces array now covers package/, both native wrapper packages, and all three example apps. link:../../package/* becomes workspace:^, nested lockfiles are gone, and the install-and-build-sdk composite action collapses to one yarn install --immutable.
  • Shared-native sync + husky setup run from the core SDK workspace's postinstall (Yarn 4 doesn't run root-workspace lifecycle scripts on install).
  • Lerna removed: release/release.config.js no longer reads lerna.json, and release / release-next / extract-changelog use yarn workspaces foreach.
  • Husky 6 → 9 (the v6 hook boilerplate is on the deprecation path).
  • .yarnrc.yml picks up the conservative hardening tier: enableHardenedMode, npmMinimalAgeGate: 3d, enableScripts: false with a small dependenciesMeta allowlist for the packages that genuinely need to build (@swc/core, better-sqlite3, react-native-nitro-modules, unrs-resolver).
  • CI workflows cache .yarn/ via setup-node; drive-by fix for the deprecated ::set-output calls in changelog-preview.yml.

🎨 UI Changes

N/A.

🧪 Testing

Locally: yarn install --immutable is clean, yarn lint + yarn build pass, husky hooks fired on every commit in this PR.

Can't verify locally: example apps on real devices, and the release flow itself. The Lerna → yarn workspaces foreach rewrite is the riskiest single change — worth a dry-run on a throwaway branch before merging.

☑️ Checklist

  • I have signed the Stream CLA (required)
  • PR targets the develop branch
  • Documentation is updated
  • New code is tested in main example apps, including all possible scenarios
    • SampleApp iOS and Android
    • Expo iOS and Android

oliverlaz added 10 commits May 13, 2026 11:37
Swap the committed Yarn binary from 1.22.22 to 4.14.1 and migrate
all seven yarn.lock files from v1 to Berry v8 format in place via
`yarn install`. Resolutions are preserved -- no dependency drift.

Yarn 4 auto-added enableScripts: true and approvedGitRepositories: "**"
(explicit defaults) to .yarnrc.yml. Nested package.json files were
reformatted by Yarn 4 with alphabetized peerDependencies keys.

This is step 1 of the broader Yarn 4 + workspaces migration. Workspaces
declaration, link: -> workspace:^ conversion, lockfile consolidation,
and Lerna removal follow in subsequent commits.
Add `workspaces` field to root package.json listing the SDK packages
and three example apps. Replace `link:../../package/*` references in
the examples with `workspace:^`, and replace the literal "8.1.0"
references between native-package/expo-package and the core SDK with
`workspace:^` so they resolve through the workspace graph.

Delete the six nested yarn.lock files in favor of a single consolidated
root lockfile. Update the install-and-build-sdk composite action to a
single `yarn install --immutable` step at the repo root.

Lift `resolutions` from native-package and expo-package to root, since
Yarn 4 only honors `resolutions` at the project root. Pin two
transitives that drifted during the consolidated install:

- react-native-keyboard-controller pinned to 1.20.2 (had drifted to
  1.21.7, whose updated types make a downstream @ts-expect-error
  unused and break the SDK type build)
- prettier pinned to 3.5.3 (had drifted to 3.8.3, which reformats two
  files in the SDK that the prior lint pass accepts)

Metro config workarounds in the example apps are intentionally left
alone -- their extraNodeModules + blockList + watchFolders machinery
is independent of the Yarn protocol and still required under
nmHoistingLimits: workspaces.
Drop the `preinstall: yarn sync-native` hook from the three example
apps. Add a root `postinstall` that runs the SDK's existing
`shared-native:sync` script via the workspace graph. The remaining
`preandroid` / `preios` / `prestart` belt-and-suspenders calls in each
example are kept untouched, so a developer running `yarn android` /
`yarn ios` / `yarn start` still gets a fresh sync.

Also drop the now-redundant `install-all` script from the core SDK's
package.json -- it predated workspaces and orchestrated separate
installs in native-package and expo-package.

Child-workspace `preinstall` lifecycle is awkward under Yarn 4 (it
runs in build-script context, not pre-install), so moving the
guarantee to the root `postinstall` is both safer and gives every
fresh clone a synced mirror without depending on the developer
running a build target first.
Replace `yarn lerna-workspaces run <task>` with the direct
`yarn workspace stream-chat-react-native-core <task>` form for the
day-to-day developer scripts (lint, lint-fix, eslint, build, test:unit,
test:coverage). Only the core SDK workspace exposes these scripts, so
the indirection via Lerna was unnecessary.

Drop the `bootstrap` script entirely -- Yarn 4 workspaces remove the
need for `lerna bootstrap`; `yarn install` at the root sets up every
workspace.

Release-driving scripts (release, release-next, extract-changelog)
still go through Lerna in this commit; they get migrated in the
next step.
Migrate the release pipeline off Lerna v4:

- release/release.config.js: stop reading lerna.json. Hardcode the
  set of release-participating workspaces as ['package',
  'examples/SampleApp'] to mirror the previous lerna `packages`
  array. monorepo-setup.js's existing symlink-detection logic
  continues to handle the Yarn workspace symlinks under node_modules.

- package.json scripts: replace `yarn lerna-workspaces run <task>`
  with `yarn workspaces foreach -A --topological-dev` for `release`,
  `release-next`, and `extract-changelog`, filtered to the two
  release-participating workspaces. Drop the `lerna-workspaces`
  helper alias.

- Delete lerna.json and remove `lerna` from root devDependencies.

The npm publish gate is unchanged -- `@semantic-release/npm` still
fires only when `currentPackage.name === 'stream-chat-react-native-core'`,
so SampleApp gets a tag + changelog entry but no publish, as before.
…stall

Drop the legacy v4-style `"husky": { "hooks": {...} }` block from
root package.json -- Husky v6 ignored it silently, so removing it
isn't a behaviour change, just clears a footgun.

Move the install-time setup (husky hook registration + shared-native
sync) onto the core SDK workspace's `postinstall`. Yarn 4 does not
run the project root's lifecycle scripts during `yarn install`, but
it does run a workspace's `postinstall` when its build state is
stale. Invoking husky via its hoisted bin path (rather than `yarn
dlx`) keeps us on the locally-pinned v6 binary; using `yarn dlx`
would pull v9 and break the existing v6-style hook scripts.

Update dotgit/hooks/pre-commit-format.sh to call `yarn lint` /
`yarn lint-fix` instead of the now-removed `yarn lerna-workspaces`
indirection. The root scripts both forward to
`yarn workspace stream-chat-react-native-core`, so behaviour is
preserved.

After this commit, a fresh `yarn install` at the repo root sets
core.hooksPath to .husky and syncs shared-native into the
native-package and expo-package mirror dirs, with no manual setup
step required.
Extend .yarnrc.yml with the conservative hardening tier:

- enableHardenedMode: strict checksum + integrity validation on the
  resolver; rejects any tampering with the lockfile or registry.
- npmMinimalAgeGate: 3d -- refuses to install npm packages younger
  than three days, mitigating the typical supply-chain "install a
  malicious 0.0.1-published-an-hour-ago" window.
- enableGlobalCache + nmMode: hardlinks-global -- shares package
  content across worktrees via the global cache and hardlinks,
  reducing duplicate on-disk content and speeding repeat installs.
- enableTelemetry: false -- explicit opt-out from Yarn telemetry.

Install scripts stay enabled in this commit; the
`enableScripts: false` + per-package allowlist follows next so the
two changes can be reverted independently if needed.

Verified `yarn install --immutable` is clean under the new
settings.
Set `enableScripts: false` so dependency build scripts no longer run
implicitly during `yarn install`. This closes the largest
supply-chain hole: a compromised transitive dependency can no longer
execute code on every developer / CI install.

Add a `dependenciesMeta` allowlist in root package.json that
re-enables build scripts for the four packages this monorepo
actually needs to compile:

- @swc/core         -- native binary, used by i18next-cli (yarn build)
- better-sqlite3    -- native binding, used by the SDK's tests
- react-native-nitro-modules -- RN native module used by SampleApp
- unrs-resolver     -- native resolver binary, used by jest-resolve

Four other packages still print "lists build scripts, but all build
scripts have been disabled" warnings on install. They are
intentionally not allowlisted:

- @firebase/util    -- internal type-generation script, ship-time
- es5-ext           -- author advertisement postinstall
- hyochan-welcome   -- "welcome" advertisement postinstall
- protobufjs        -- proto compilation, not needed for pre-built
                       package on npm

The core SDK workspace's own `postinstall` (husky install +
shared-native sync) continues to fire -- `enableScripts: false`
gates dependency install scripts, not workspace lifecycle scripts.

Verified `yarn install --immutable`, `yarn lint`, and `yarn build`
all pass with the allowlist in place.
Update GitHub Actions workflows for the Yarn 4 + workspaces world:

- check-pr.yml, release.yml, sample-distribution.yml: add
  `cache: 'yarn'` + `cache-dependency-path: 'yarn.lock'` to every
  actions/setup-node@v6 invocation so the workflows hit the
  .yarn/cache between runs. sdk-size-metrics.yml already had this.

- check-pr.yml, release.yml: replace `yarn lerna-workspaces run lint`
  with the plain `yarn lint` script (which forwards to the SDK
  workspace) -- lerna is gone.

- check-pr.yml: replace `cd package && yarn test:typecheck` with
  `yarn workspace stream-chat-react-native-core test:typecheck` for
  consistency with the rest of the workflow.

- changelog-preview.yml: collapse the four sequential
  `yarn --frozen-lockfile` / `yarn` invocations (root, package/,
  native-package/, examples/SampleApp/) into a single
  `yarn install --immutable` at the workspace root. Move the
  setup-node step after checkout (where it belongs) and drop the
  pre-checkout duplicate.

- sample-distribution.yml: reorder checkout to run before
  setup-node so the cache step can find yarn.lock.
Update CLAUDE.md's "Common Commands" section for the Yarn 4
workspaces world:

- Explain the committed Yarn 4 binary + yarnPath dispatch (no
  Corepack required).
- Replace `cd package && yarn ...` examples with root-level
  `yarn lint`, `yarn build`, `yarn test:unit` (forwarded via
  `yarn workspace`).
- Drop the `yarn install --frozen-lockfile` / `yarn install-all`
  snippets; explain the single-root `yarn install` with its
  postinstall (husky install + shared-native sync).
- Note that Lerna is gone and the release pipeline runs via
  `yarn workspaces foreach`.

Update examples/README.md to clarify that the three example apps in
this repo are now workspaces wired via `workspace:^`; keep the
existing `link:` guidance scoped to "consuming the SDK from an app
outside this monorepo," which is its actual use case.
@Stream-SDK-Bot
Copy link
Copy Markdown
Contributor

Stream-SDK-Bot commented May 13, 2026

SDK Size

title develop branch diff status
js_bundle_size 367 KB 1797 KB +1464893 B 🔴

oliverlaz added 9 commits May 13, 2026 13:51
- Remove `.claude/plans/migrate-to-yarn-4.md` -- the plan was useful
  during the migration but doesn't need to live in the repo.

- Bump husky 6.0.0 -> 9.1.7. The v9 hooks layout sets core.hooksPath
  to `.husky/_/` (not `.husky/`), with auto-generated stubs in that
  directory routing each hook to the user-authored counterpart at
  `.husky/<hook>`. The user hook files no longer need the
  `#!/bin/sh` + `. "$(dirname "$0")/_/husky.sh"` boilerplate; in v9
  that boilerplate emits a deprecation warning and is slated to
  break in v10. Trimmed those two lines from `.husky/pre-commit` and
  `.husky/commit-msg`, and deduped the accidental double commitlint
  invocation in `.husky/commit-msg` while there.

- Update the core SDK workspace's postinstall to invoke the v9
  binary path (`node_modules/husky/bin.js` -- the `install` subcommand
  is gone in v9; running the binary with no args performs the same
  setup).
- changelog-preview.yml: replace the `echo "::set-output name=..."`
  invocations with the modern `>> "$GITHUB_OUTPUT"` form. GitHub
  deprecated `::set-output` in 2022 and has been emitting workflow
  warnings on every run. Switch the multi-line `preview` value to the
  heredoc form so the urlencoding of %, \n, \r is no longer needed.

- .gitignore: tighten the .yarn/ rules. The existing `.yarn/*` is
  root-anchored and doesn't catch the transient `install-state.gz`
  that Yarn 4 emits if someone runs `yarn install` inside a workspace
  child. Add explicit `**/.yarn/install-state.gz` and `**/.yarn/cache`
  patterns so those don't accidentally end up in a commit.
Remove the two `resolutions` entries that were added during the
Yarn 4 + workspaces migration as guardrails against the downstream
effects of workspace consolidation. With the migration landed, the
guardrails can come off:

- prettier (was pinned to 3.5.3, drifted to 3.8.3): run
  `yarn workspace stream-chat-react-native-core prettier-fix` so the
  files prettier 3.8 wants to reflow get reflowed once and from now
  on `yarn lint` stays green. Affects CHANGELOG.md, i18next.config.ts,
  metro-dev-helpers/lib/find-linked-packages.js, the two
  videoThumbnail.ts copies (native + expo), generateThumbnail.ts,
  ReactionListClustered.tsx, and Poll.tsx -- purely whitespace /
  trailing-comma differences, no semantic changes.

- react-native-keyboard-controller (was pinned to 1.20.2): drop the
  pin so it floats to ^1.20.2. Upstream tightened the
  `KeyboardAvoidingViewProps` typings such that the previous
  `@ts-expect-error` directive in `KeyboardControllerAvoidingView.tsx`
  is now genuinely unused -- TS rejects it with TS2578 if left in.
  Drop the directive; the surrounding code compiles cleanly.

Verified: `yarn install --immutable` clean, `yarn lint` exit 0,
`yarn build` produces lib/{commonjs,module,typescript}.
Patch bump within the existing ^25 range; no behaviour changes
expected. Run `yarn up semantic-release` to refresh the lockfile.
Yarn 4 auto-added `approvedGitRepositories: ["**"]` to .yarnrc.yml
during the v1 -> v8 lockfile migration. That wildcard allows any
git-protocol dependency to install, which defeats the point of
running with `enableHardenedMode: true`.

The lockfile has zero git-protocol resolutions, so dropping the key
costs us nothing today; future PRs that try to introduce a
`github:owner/repo` or `git+ssh://...` dep will get rejected at
install time and the reviewer will have to explicitly opt the
repository in.
Move all ESLint/Prettier deps and one shared flat config to the repo
root so every workspace uses the same rule set. The three
sub-workspace eslint.config.mjs files (core, SampleApp,
TypeScriptMessaging) are removed; flat-config auto-discovery walks up
from any cwd. Root `lint`/`lint-fix` now run in place instead of
proxying to core, and `validate-translations` is still invoked via
`yarn workspace stream-chat-react-native-core`.

The shared config switches from the legacy
`@react-native-community/eslint-*` family to the modern
`@react-native/eslint-config` + plugin and applies the same strictness
across `package/**` and `examples/**`. `import/no-unresolved` is
disabled repo-wide because the legacy node resolver doesn't understand
Yarn 4 workspace hoisting or TS path aliases - TypeScript already
catches these.

Bump the laggard plugins while keeping ESLint on 9 and
@react-native/eslint-config on 0.81.6:
- eslint-plugin-jest 28 -> 29
- eslint-plugin-react-hooks 5 -> 7
- eslint-plugin-prettier 5.4.1 -> 5.5.5
- eslint-config-prettier 10.1.5 -> 10.1.8

Also include the manual @commitlint/* major bump (12 -> 21) and
refreshed eslint/typescript-eslint/prettier floors.

Extend `.prettierignore` to exclude native scaffolding, `.github/`,
`.claude/`, `ai-docs/`, top-level docs and CHANGELOGs. The latent
issues this surfaced in example-app code are addressed with targeted
per-line `eslint-disable-next-line` comments rather than relaxing
rules globally.

CI (`yarn lint` in check-pr.yml / release.yml) and the pre-commit hook
keep working without changes.
Move .editorconfig from package/ to the repo root so it covers every
workspace.

Drop dead code:
- package/rollup-react-native-image.js: no callers, no rollup dep, no
  rollup.config; carried over from a pre-builder-bob layout.
- package/metro-dev-helpers/: not in core's published `files` allowlist
  (would 404 for any npm consumer following the README recipe), no
  in-repo users, recipe used the legacy Metro `blacklistRE` API that
  examples/SampleApp/metro.config.js already supersedes. README updated
  to point at the live SampleApp metro config.
- examples/{SampleApp,TypeScriptMessaging}/__tests__/App-test.tsx and
  their jest.config.js: unmodified RN scaffold smoke tests; CI never
  runs example tests (root test:coverage proxies to core), SampleApp's
  own test:unit script was literally `echo Skipping...`. Drops jest,
  react-test-renderer, @types/jest, @types/react-test-renderer from
  both example apps.
- babel-eslint@10.1.0 from package/devDependencies: deprecated package
  and the source of a recurring peer-warning during install.

Sync version pins:
- root resolutions: @types/react ^19.0.0 -> ^19.1.0 to match actual
  usage across workspaces.
- examples/{SampleApp,TypeScriptMessaging}: @types/jest ^29.5.14 ->
  ^30.0.0 to match the jest 30 already installed.

ESLint flat-config future-proofing:
- Drop the legacy `/* eslint-env node */` comment in
  package/metro-dev-helpers/extract-linked-packages.js (the file was
  deleted) and there are no others left. Removes the deprecation
  warning that ESLint 10 will turn into an error.

Add engines.node `>=20.19.4` to root package.json (mirrors the floor
SampleApp already declared).
Hoist typescript to root only -- previously declared in package/,
SampleApp, and TypeScriptMessaging at the same version (5.8.3); now
root-owned and resolved via Yarn 4 walk-up. ExpoMessaging keeps its
own ~5.9.x. Removing @types/jest from examples (done in the previous
cleanup) had broken the `"types": ["jest"]` setting inherited from
@react-native/typescript-config, so override `"types": []` in the two
example tsconfigs.

Add a root `typecheck` aggregator + per-workspace `typecheck: tsc
--noEmit` scripts in core (mirroring test:typecheck), SampleApp,
TypeScriptMessaging and ExpoMessaging. Wired via
`yarn workspaces foreach -A --parallel ... run typecheck`. Core
passes. The example apps surface pre-existing TS errors that were
never typechecked in CI (missing `stream-chat` types, `Property
'colors' does not exist on type 'Theme'`, nullability) -- the
aggregator just exposes them; CI is unchanged.

Bring `release/` into lint scope and fix the two issues it surfaced:
- release/next.js: reorder `execa` import before `semantic-release`
- release/monorepo-setup.js: drop unnecessary `\.` escape

Address the lint loose ends from the previous migration:
- require-await x3: drop `async` keyword where the body is pure-sync
  (opSqliteSwiftPlugin's `withDangerousMod` callback, SampleApp's
  `deleteConversation` and `onAlsoSentToChannelHeaderPress`).
- import/order x3 in SampleApp/App.tsx: move the
  `Geolocation.setRNConfiguration({...})` call *after* all imports so
  the import block is contiguous.
- react/no-unstable-nested-components: extract
  `renderLogoutButton` to module scope in ExpoMessaging/app/index.tsx
  so the header doesn't get a fresh component type each render.
- no-unsafe-optional-chaining: drop the redundant optional chaining
  in usePaginatedUsers.ts (the function already guards chatClient
  via `!chatClient?.userID` earlier).
- Annotate the remaining intentional disables (7 exhaustive-deps in
  ExpoMessaging, 1 no-underscore-dangle for stream-chat's `_setToken`)
  with `-- <reason>` rationale so future readers see the why.

Pin `settings.react.version: '19'` in eslint.config.mjs -- `detect`
fails from the root config location because React isn't a direct root
dep, which produced a recurring `React version not specified` warning
on every lint invocation.
Two related peer-warning fixes:

1) @shopify/flash-list -- examples/ExpoMessaging pinned 2.0.2 while
   core's peerDependency required >=2.1.0. Bump every workspace to a
   2.3.x baseline:
   - examples/ExpoMessaging: 2.0.2 -> ^2.3.1
   - examples/SampleApp: ^2.1.0 -> ^2.3.1
   - package (peer): >=2.1.0 -> >=2.3.0
   - package (devDep): ^2.1.0 -> ^2.3.1

2) @react-native/eslint-config (and its companion -plugin) -- drop
   both entirely. The config contributed:
   - No @react-native/* rules of its own (the plugin was unused).
   - ~120 mostly-default rule levels that overlap with what we already
     get from @eslint/js.recommended, tsEslint.configs.recommended and
     eslintPluginReact.configs.flat.recommended.
   - 43 globals, all but one (__DEV__) covered by the standard
     `globals` package presets (browser + node + es2021).

   The bundled @typescript-eslint/eslint-plugin@7.18.0 it dragged in
   was also the source of an "eslint 9.39.4 doesn't satisfy ^8.57.0"
   peer warning -- gone now that the package is removed.

   What changes in our config:
   - Replace dynamic globals/rules extraction with explicit values:
     globals.browser + globals.node + globals.es2021 + __DEV__.
   - Replace `...reactNativeRules` spread with explicit rule
     declarations. Behavior preserved for everything our codebase
     touches; a handful of recommended-preset rules
     (no-constant-condition, no-empty, no-inner-declarations,
     no-redeclare, react/display-name, react/no-unknown-property,
     react/react-in-jsx-scope) are silenced explicitly to match the
     prior state.
   - Re-enable three rules at `warn` that the RN config had on and
     that catch real footguns: react/no-unstable-nested-components,
     no-bitwise, no-extend-native. Keeps existing eslint-disable
     directives in example apps valid.
   - Fix the React-version warning placement: settings are now a
     standalone top-level config object so they cover
     eslintPluginReact.configs.flat.recommended (previously they only
     applied to the 'default' overlay, so the warning kept firing).

Adds `globals: ^17.6.0` to root devDependencies. Removes
@react-native/eslint-config and @react-native/eslint-plugin from root
devDependencies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants