Skip to content

feat: stack navigator for Lynx on React Navigation 8 - #1

Merged
upupming merged 21 commits into
mainfrom
feat/stack-navigator
Aug 25, 2026
Merged

upupming merged 21 commits into
mainfrom
feat/stack-navigator

Conversation

@upupming

@upupming upupming commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

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 to main

@react-navigation/core resolves to its TypeScript source through the @react-navigation/source export condition, the same way the upstream monorepo resolves its own packages, so neither submodule needs a build step. The bundler needs the same condition plus extensionAlias — both are in examples/stack/lynx.config.js with the reasoning.

Structure

@react-navigation/lynx mirrors what @react-navigation/native does 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.ts and 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 LynxExample host app from lynx-screens:

  • pushing and popping card screens works
  • a screen guarded by usePreventRemove survives the hardware back button, with PreventNativeDismissCallback firing and no dismiss following it

That 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 the onNativeDismissPrevented round-trip popped the route anyway, because only a beforeRemove listener cancels that dispatch. Upstream has no such option for the same reason. Prevention now reads preventedRoutes and nothing else.

Notes for review

  • typecheck is a plain tsc --noEmit and 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 of BaseNavigationContainer's addListener and reports five errors that upstream's own CI does not see. @types/react is pinned to react-navigation's ~19.2.17 for the same reason.
  • The example's type check still excludes react-navigation. It is not a Node program, and BaseNavigationContainer reaches for Error.captureStackTrace.
  • The compat layer covers use, startTransition and useInsertionEffect. The first two are fine; useInsertionEffect is mapped to useLayoutEffect, which is not equivalent — 12 of core's 15 uses are plain latest-ref writes where the difference is invisible, but useRegisterNavigator, usePreventRemove and PreventRemoveProvider also register cleanups. Preact 11 is expected to close this.

Known gaps

The example does not exercise everything it names:

  • Transition events subscribes on its own screen, so it only ever sees closing=false. The closing=true event fires on the screen being dismissed, which by then is unmounting. Showing it needs the listener on the parent, or a second one on Detail.
  • Preload reports local state set next to the 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, or contentStyle.

No license field on the package — the repository has none yet.

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.
@socket-security

socket-security Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
License policy violation: npm typescript

License: LicenseRef-W3C-Community-Final-Specification-Agreement - The applicable license policy does not permit this license (5) (package/ThirdPartyNoticeText.txt)

From: pnpm-lock.yaml → npm/typescript@5.9.3

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/typescript@5.9.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
License policy violation: npm typescript

License: LicenseRef-W3C-Community-Final-Specification-Agreement - The applicable license policy does not permit this license (5) (package/ThirdPartyNoticeText.txt)

From: examples/stack/package.json → npm/typescript@6.0.3

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/typescript@6.0.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

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.
@pkg-pr-new

pkg-pr-new Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
pnpm add https://pkg.pr.new/react-navigation/react-navigation-lynx/@react-navigation/lynx@84323ea

commit: 84323ea

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-bot

changeset-bot Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 84323ea

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@react-navigation/lynx Minor

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`.
@upupming
upupming force-pushed the feat/stack-navigator branch 3 times, most recently from 9d8798e to 59a6e7d Compare August 25, 2026 10:32
software-mansion-labs/lynx-screens#119 landed as a squash, so the branch commit
this used to track is unreachable. Back on main.
@upupming
upupming force-pushed the feat/stack-navigator branch from 59a6e7d to 8e20924 Compare August 25, 2026 10:32
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.
@upupming
upupming marked this pull request as ready for review August 25, 2026 10:36
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.
@upupming
upupming merged commit 885cf27 into main Aug 25, 2026
6 checks passed
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.

1 participant