Repository navigation
feat: stack navigator for Lynx on React Navigation 8 - #1
Conversation
Bootstraps the repo around two submodules: react-navigation pinned to the new native stack branch (#13115) and lynx-screens. Both move fast enough that consuming releases would stall this package, and core resolves to its TypeScript source via the @react-navigation/source condition, so neither needs a build step. @react-navigation/lynx is the platform layer, mirroring what @react-navigation/native does for React Native - it re-exports core and navigators depend on it rather than on core directly, per the structure Satyajit suggested. The stack navigator follows the new native stack's shape: a reducer that tracks which routes stay rendered through a pop, a view that splits routes into screens, and one component per screen. The reducer and its 21 tests are ported verbatim - it is platform-agnostic, so only imports and type names change, and the tests run under ReactLynx's testing library. Card presentation only for now. Form sheet needs lynx-screens#73, and headers need an elements equivalent.
Three card screens, one of which refuses the native dismiss so hardware back can be checked against it. Two things the bundler needs that the tsconfig alone doesn't cover: - the @react-navigation/source condition, or core resolves to a lib/ that was never built and the package looks like it has no exports - extensionAlias, because lynx-screens' sources import each other with .js suffixes that point at .ts/.tsx files
A static preventNativeDismiss option looked reasonable and was not: on device the native side did block the back press and reported it, and then the onNativeDismissPrevented round-trip popped the route anyway. That dispatch is only ever cancelled by a beforeRemove listener, which is what usePreventRemove installs - an option alone has nothing to cancel it. Upstream has no such option for the same reason, so this drops it and reads preventedRoutes like the new native stack does. Verified on a Pixel 5: pushing the guarded screen and pressing back leaves it in place, with PreventNativeDismissCallback firing and no dismiss following it.
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
Modelled on react-navigation's workflow, with two differences that matter here. The checkout takes submodules: core and lynx-screens are consumed as source, so without them there is nothing to build. Building the example is its own job. The source export condition and the extension aliasing live in the bundler config, and nothing else exercises them - a green typecheck says nothing about whether the app still bundles. Also pins core-js in allowBuilds. pnpm writes a placeholder there the first time it meets a package with a build script, and a placeholder fails the install, which in turn fails every script that runs a deps check first.
Modelled on the new native stack's example, minus the parts this navigator cannot do yet. Upstream has thirteen screens; six are header demos and two are sheets, so what is left and reproducible is: - stack depth, with push / popTo / popToTop and the live route list - prevent remove, with a guard that can be toggled off so the same gesture can be seen both blocked and allowed - transition events, logging transitionStart / transitionEnd - preload, rendering a route while it is still detached Also drops inactiveBehavior from the options. It was declared and never read, and it could not be honoured anyway: lynx-screens' activity mode is attached or detached, with nothing to express pause or unmount. Same mistake as the preventNativeDismiss option - a type that promises what the runtime does not do.
The tsconfigs mapped "react" onto the compat module through "paths". That
applies to the whole program, including @lynx-js/react's own declarations,
whose "import type { Dispatch, SetStateAction } from 'react'" then resolved
to a module that re-exports @lynx-js/react and has neither - circular, and
enough to collapse useState's inference so every setter callback argument
became "any".
Typing and runtime want different things here. Types should resolve to
@types/react, which is what core is written against; only the runtime needs
"react" aliased onto the compat layer, and the bundler already does that.
Vendored diagnostics drop from 56 to 11 - three quarters of what looked like
an irreconcilable type-universe mismatch was this mapping. The example also
gains a tsconfig, which it never had, and the three explicit parameter
annotations added to work around the phantom "any" are gone.
Verified on a Pixel 5: stack depth pushes to level 3 and popToTop returns
home, and the guarded screen counts two blocked back presses then lets the
third through once the guard is off.
The example used the dynamic Screen-as-children API while upstream's uses static configuration, and that was a symptom rather than a style choice: createStaticNavigation lives in @react-navigation/native, not core, so this package simply did not have it. createNavigatorFactory already accepts a config object - only the piece that renders one was missing. Ports the two pieces that belong to a platform layer, both of which turn out to have no React Native dependency at all: - NavigationContainer, wrapping BaseNavigationContainer with a theme - createStaticNavigation Theme follows the same shape as React Native's and is merged into core's empty Theme interface the way that package does it, so themes carry across unchanged. Left out, and marked as such: deep linking needs a Lynx URL source, state persistence needs a Lynx storage binding, and useDocumentTitle is a browser concern. The hardware back button is deliberately absent - the native stack already handles it per screen, so a container-level handler would fight it. The example now reads like upstream's. Verified on a Pixel 5: the static config renders and pushes, and initialParams from the config arrives at the screen.
Follows lynx-stack's setup: changesets for versioning, and a preview publish that only runs when a changeset actually bumps a publishable package, so PRs that touch nothing releasable stay quiet. One difference their layout does not have to deal with. The submodules are workspace members, so a naive dependency-closure walk picks up @react-navigation/core, @react-navigation/routers and lynx-screens - all upstream's packages. Publishing those under a preview URL would put someone else's code out under our name, so the closure stops at packages/. The workspace: dependency on core is safe to leave alone: it resolves to the vendored copy's version, which is already on the registry.
commit: |
The previous commit picked up a pointer to a branch whose PR is closed, so a fresh clone would have checked out a commit that exists only on that branch. The kapt fix it carried is not upstream and has to be applied locally to build LynxExample - see the README.
Two more pieces from lynx-stack's setup, both on push to main: - changesets/action opens a "Version Packages" PR that consumes the pending changesets. Merging it is what publishes; the job itself publishes nothing. - every commit also goes out as a canary, so a fix can be tried without waiting for that PR. Canaries are renamed rather than tagged - @react-navigation/lynx ships them as @react-navigation/lynx-canary - which keeps the real package's version list free of throwaway builds. The rewrite skips the submodules for the same reason the preview publish does: they are upstream's packages. changeset version needs GITHUB_TOKEN even when it publishes nothing; the changelog plugin reads PR metadata from the API and fails outright without it. Also pins @types/react across the workspace. lynx-screens asks for 18 while everything else is on 19, and two copies of React's types mean two incompatible ReactNode types at every component boundary between them. That was two of the diagnostics the typecheck script had been swallowing.
🦋 Changeset detectedLatest commit: 84323ea The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
It recorded a canary version from testing the snapshot rewrite locally, not anything that was released. The real one is written by the version PR.
Drops NPM_TOKEN in favour of the OIDC exchange. pnpm 11 implements it - it
ships publish/oidc/{authToken,idToken,provenance} and reads
ACTIONS_ID_TOKEN_REQUEST_* - so `pnpm publish` and `changeset publish` both
work with `id-token: write` and no secret.
No setup-node registry-url step, deliberately: it writes an .npmrc pointing at
NODE_AUTH_TOKEN, and trusted publishing only kicks in when npm finds no token.
The canary job stays pinned to this repository. A fork has no trusted-publisher
entry, so the exchange would fail there rather than being skipped.
Renaming to @react-navigation/lynx-canary meant a second package to create and a second trusted publisher to configure, for no gain that a dist-tag does not already give. Canaries now go out as @react-navigation/lynx with `--tag canary`, so `latest` still points at the last real release. That leaves the snapshot script doing one thing: shortening the version changesets writes, from a full timestamp and SHA down to a date and eight characters. Dependency and peer rewriting are gone with the rename - pnpm resolves `workspace:` at publish time.
…pper The nine diagnostics the wrapper was swallowing came from two causes, neither of them ours. Five were TypeScript 5.9 failing to infer what upstream's `^6.0.3` infers - `BaseNavigationContainer`'s `addListener` loses its contextual type, its parameters go implicitly `any`, and the `@ts-expect-error` guarding the call inside it then reads as unused. Reproducible with react-navigation's own root tsconfig and its own `@types/react`, so it was never a Lynx problem. Matching their compiler version clears all five. The other four were unused `React` imports in lynx-screens, sent upstream as software-mansion-labs/lynx-screens#119. The submodule points at that branch until it merges. `@types/react` moves to `~19.2.17` for the same reason: react-navigation's sources are the ones we compile, so we should compile them against what upstream does. The override stays - lynx-screens still pins 18. With nothing left to swallow, `typecheck` is `tsc --noEmit`. The example keeps a narrower exclusion: it is not a Node program, and upstream reaches for `Error.captureStackTrace`.
9d8798e to
59a6e7d
Compare
software-mansion-labs/lynx-screens#119 landed as a squash, so the branch commit this used to track is unreachable. Back on main.
59a6e7d to
8e20924
Compare
repository, homepage, bugs and keywords, matching what every other @react-navigation package carries. No license field - the repository has none yet, and that is not this PR's call to make.
The version PR is long-lived and rewritten on every push to main, so a fixed title says nothing about whether it moved. Same shape lynx-stack uses, through v1's `title` rather than v2's `pr-title`.
v1's inputs were renamed - `publish`, `title` and `commit` are now `publish-script\', `pr-title` and `commit-message`. Unknown inputs are a warning rather than an error, so the old names would have silently fallen back to "Version Packages". Pinned to a commit: there is no moving `v2` tag to follow.
@lynx-js/types 3.4.11 -> 4.1.0 and @lynx-js/testing-environment 0.2 -> 0.3.3. Everything else was already current.
lynx-screens is on React 19's types now, so nothing in the workspace asks for 18 and there are no longer two ReactNode types to reconcile. The override was never a fix for lynx-screens - a consumer installing it from npm never sees its devDependencies. It was the cost of vendoring it as a workspace member, where they do get installed. Depends on software-mansion-labs/lynx-screens#120; the submodule tracks that branch until it merges.
software-mansion-labs/lynx-screens#120 landed as a squash, so the branch commit this tracked is unreachable. Back on main.
First cut of a stack navigator for Lynx, built on React Navigation 8.
Layout
Two submodules rather than npm dependencies. React Navigation 8 and Lynx Screens 5 are both moving quickly and this package has to track them closely enough that waiting on releases would stall it.
react-navigation/— pinned to the new native stack branch (#13115)lynx-screens/— pinned tomain@react-navigation/coreresolves to its TypeScript source through the@react-navigation/sourceexport condition, the same way the upstream monorepo resolves its own packages, so neither submodule needs a build step. The bundler needs the same condition plusextensionAlias— both are inexamples/stack/lynx.config.jswith the reasoning.Structure
@react-navigation/lynxmirrors what@react-navigation/nativedoes for React Native: it is the platform layer, it re-exports core, and navigators sit on top of it rather than on core directly. Navigators are entrypoints of the same package (./stack) for now, since everything here will be screens-based.The navigator
Follows the new native stack's shape: a reducer tracking which routes stay rendered through a pop, a view splitting routes into screens, one component per screen.
LynxStackViewState.tsand its 21 tests are ported verbatim — the reducer is platform-agnostic, so only imports and type names change, and the tests run under ReactLynx's testing library.Card presentation only. Form sheet needs software-mansion-labs/lynx-screens#73, and headers need an elements equivalent.
Verified on device
Pixel 5, via the
LynxExamplehost app from lynx-screens:usePreventRemovesurvives the hardware back button, withPreventNativeDismissCallbackfiring and no dismiss following itThat last one caught a real bug. The options type first carried a static
preventNativeDismiss, which looked reasonable and was not: the native side did block the press and report it, and then theonNativeDismissPreventedround-trip popped the route anyway, because only abeforeRemovelistener cancels that dispatch. Upstream has no such option for the same reason. Prevention now readspreventedRoutesand nothing else.Notes for review
typecheckis a plaintsc --noEmitand passes over this package and both submodules. That needs TypeScript 6: react-navigation's sources are compiled here and its root config asks for^6.0.3, which 5.9 is not a substitute for — 5.9 loses the contextual type ofBaseNavigationContainer'saddListenerand reports five errors that upstream's own CI does not see.@types/reactis pinned to react-navigation's~19.2.17for the same reason.BaseNavigationContainerreaches forError.captureStackTrace.use,startTransitionanduseInsertionEffect. The first two are fine;useInsertionEffectis mapped touseLayoutEffect, which is not equivalent — 12 of core's 15 uses are plain latest-ref writes where the difference is invisible, butuseRegisterNavigator,usePreventRemoveandPreventRemoveProvideralso register cleanups. Preact 11 is expected to close this.Known gaps
The example does not exercise everything it names:
closing=false. Theclosing=trueevent fires on the screen being dismissed, which by then is unmounting. Showing it needs the listener on the parent, or a second one onDetail.preload()call rather than anything observable about the preloaded route. The reducer handles preloading and has tests for it; the screen just doesn't show it.Neither is a navigator defect, and both are demo-side. Nothing here covers nested navigators,
useFocusEffect,replace/reset, orcontentStyle.No
licensefield on the package — the repository has none yet.