Skip to content

feat(react,vue,solid): add closed and loading to route views - #106

Draft
sergeysova wants to merge 24 commits into
mainfrom
claude/createroute-otherwise-loading-h1g5nw
Draft

feat(react,vue,solid): add closed and loading to route views#106
sergeysova wants to merge 24 commits into
mainfrom
claude/createroute-otherwise-loading-h1g5nw

Conversation

@sergeysova

@sergeysova sergeysova commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Brings back the atomic-router skeleton pattern (nested CreateRouteView with otherwise) in the shape this router uses — as two new properties of createRouteView / createLazyRouteView in the React, Vue, and Solid bindings:

  • closed — rendered while the route is not opened;
  • loading — rendered while the route is pending (beforeOpen effects, a chainRoute preparation) and while a lazy chunk loads.

A view now reads as its three states: view when opened, loading while pending, closed while closed. createRoutesView({ otherwise }) keeps its name and its different meaning — nothing matched the URL.

createRoutesView and Outlet still render a single view. Resolution order:

  1. the deepest opened view (unchanged behaviour, including parent suppression and last-declared-sibling priority);
  2. otherwise the loading of a pending view;
  3. otherwise a hold: the view most recently resolved here, while any route in the list is still pending;
  4. otherwise the closed of a closed view — only once something in the list has genuinely opened or been pending at least once, so it can't permanently shadow otherwise for a URL that never matched anything in the list;
  5. otherwise the otherwise prop of createRoutesView (null inside an Outlet).

Because an opened view always wins, loading never replaces a page already on screen. Fallbacks are wrapped by the view's layout and by its withLayout group, so a grouped layout stays mounted while a nested chain resolves — the parent view keeps rendering while its Outlet shows the child's skeleton.

This also removes the flash of the routes-view otherwise (the not-found screen) that appeared for a split second while a lazy view's chunk or chained data was loading: createLazyRouteView's fallback only covered the chunk request, so a pending route fell through to the not-found screen. fallback is deprecated in favour of loading, which covers both waits, and keeps working as its alias.

Transition hold (step 3 above)

Closing the previous route and opening the next one is not atomic, so for one instant nothing in a createRoutesView/Outlet's list is opened. Without the hold, that instant fell through to closed/otherwise on every ordinary navigation, not just ones with a declared loading — flashing the not-found screen and tearing down any withLayout group around it. The hold applies only while something is pending; a genuinely unmatched URL still shows otherwise immediately, and the very first render (nothing resolved yet) falls through normally.

In the Solid binding this closes most, but not all, of the gap: its fine-grained reactivity can observe the previous route closing and the next one becoming pending as two separate ticks (React and Vue's schedulers coalesce them), occasionally costing one extra layout remount recovering from a transient otherwise frame. Navigation still converges on the right page; this is documented as a known limitation in the Solid reference and pinned by two tests that assert the observed behaviour.

Fixes from review (latest round)

A follow-up review of the resolution logic surfaced several edge cases, each fixed with a reproducing test first:

  • otherwise no longer gets shadowed by an unrelated closed. Any route in the list with a closed fallback used to win as soon as nothing was open, even for a URL that never matched anything in the list.
  • Solid: a held view no longer resurfaces after the user has already navigated away. The close/open-gap hold never cleared once set, so an abandoned view could resurface if an unrelated route elsewhere in the list started pending much later. It now clears whenever nothing claims the frame, while still covering the original gap.
  • React: stopped mutating a ref during render. The previously-resolved view is now committed from a useLayoutEffect instead of the render body, since a render pass can be discarded without committing (Strict Mode's double-invoke, an interrupted concurrent render).
  • Vue: the resolved view is now identity-stable, matching React and Solid — unrelated router churn that doesn't change the selection no longer hands watchers a new object.
  • The shared priority-resolution algorithm (opened → loading → hold → closed → otherwise) that was hand-copied across the three bindings is now one pure function, resolveRouteView, exported (@internal) from @effector/router with its own unit test suite; each binding is left with only its own reactive glue around it.

Other changes

  • Render-stability fix (React): createRoutesView/Outlet no longer re-render their surrounding tree on every store tick that doesn't change the resolved view — memoized resolution, a React.memo boundary, and a memoized outlet-context value.
  • How-to guide: docs/how-to/nested-loading-skeletons.md — a tested, code-backed React example for nested loading skeletons, plus a "Variations" chapter of short recipes for adjacent goals (e.g. skeleton only on first load, layout-only holds, error boundaries alongside closed).

Each binding's resolve-route-view module wraps the shared core algorithm with reactive glue and owns the fallback wrapping; createRoutesView and Outlet now render through it. useOpenedViews keeps its current public semantics (opened views only).

React Native re-exports the React factories, so the props type-check there; the @react-navigation navigators mount every route view as a screen and render view only — documented in the React Native reference.

Type of change

  • ✨ Feature
  • 🐛 Bug fix
  • ⚡ Performance
  • 📝 Documentation

Checklist

  • I linked a related issue (or explained why there isn't one) — no issue; requested directly in chat
  • pnpm build passes
  • pnpm typecheck passes (plus pnpm verify:typecheck-standalone, pnpm :docs typecheck, pnpm :docs check-links)
  • pnpm test passes and I added/updated tests for my change (new specs across the three bindings plus a new core-level suite; Solid example e2e also run against a local Chromium)
  • pnpm lint passes
  • I added a changeset (minor/patch across @effector/router, @effector/router-react, @effector/router-vue, @effector/router-solid)

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1d11204

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

This PR includes changesets to release 4 packages
Name Type
@effector/router-react Minor
@effector/router-solid Minor
@effector/router-vue Minor
@effector/router Patch

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

@netlify

netlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploy Preview for effector-router ready!

Name Link
🔨 Latest commit 1d11204
🔍 Latest deploy log https://app.netlify.com/projects/effector-router/deploys/6a923630601b7b000865a3de
😎 Deploy Preview https://deploy-preview-106--effector-router.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Adds failing specs for the upcoming `otherwise` and `loading` properties of
`createRouteView`/`createLazyRouteView` in every web binding: a closed route
renders `otherwise`, a pending route renders `loading`, an opened sibling still
wins over any declared fallback, fallbacks render through `Outlet`, they are
wrapped by the view layout (including `withLayout` groups), and `loading`
doubles as the lazy chunk fallback.
`createRouteView`/`createLazyRouteView` accept two new components:

- `otherwise` — rendered while the route is not opened;
- `loading` — rendered while the route is pending, and used as the lazy chunk
  fallback unless the chunk-only `fallback` is declared.

Both take part in the routes view / `Outlet` selection: an opened view always
wins, and only when nothing is opened the last declared fallback renders, with a
pending `loading` taking precedence over a closed `otherwise`. Fallbacks are
wrapped by the same layout the view uses, including `withLayout` groups, so a
grouped layout stays mounted while a nested chain resolves — the skeleton
pattern that `atomic-router`'s nested route views used to cover.

Each binding gained a mirrored internal `resolve-route-view` module owning the
fallback wrapping and the resolution; `createRoutesView` and `Outlet` now render
through it.
Covers the new properties in the React, Vue, and Solid reference pages for
createRouteView/createLazyRouteView — including the selection order used by
createRoutesView and Outlet, the layout wrapping, and the caveat about a
per-view otherwise shadowing the routes-view not-found screen.

Also notes the pending-state skeleton in the chainRoute reference, records that
native navigators render `view` only, and adds the mirrored resolve-route-view
module to AGENTS.md.
@sergeysova
sergeysova force-pushed the claude/createroute-otherwise-loading-h1g5nw branch from 8b8e72d to f816d2b Compare July 29, 2026 11:29
Locks the contract that removes the routes-view `otherwise` flash: the lazy
`fallback` component also covers the pending route, not just the chunk request,
and `loading` wins when both are declared.
`fallback` only covered the chunk request, so a lazy view whose route was still
pending fell through to the routes view `otherwise` — the not-found screen
flashed for a moment before the page appeared.

`loading` covers both waits, and `fallback` now behaves as its alias: it is used
when `loading` is absent, and it also renders while the route is pending. The
type is marked `@deprecated` so editors point at `loading`.
Rewrites the lazy route view examples and property tables around `loading`,
documents `fallback` as its deprecated alias, and notes that covering both waits
with one component is what stops the routes view `otherwise` from flashing.
…osed

`closed` is the exact complement of `opened` and matches the router's own
lifecycle vocabulary, so a view now reads as its three states: `view` when
opened, `loading` while pending, `closed` while closed.

It also removes the clash with `createRoutesView({ otherwise })`, which keeps
its name and its different meaning — nothing matched the URL. That prop is
untouched.
@sergeysova sergeysova changed the title feat(react,vue,solid): add otherwise and loading to route views feat(react,vue,solid): add closed and loading to route views Jul 29, 2026
Answers the question this feature came from: how to keep a skeleton per nesting
level while a chain of routes prepares, instead of letting the not-found screen
flash between navigations.

The guide is task-oriented, so it lives in the how-to quadrant, which until now
was an empty placeholder — it is now listed in the sidebar and the top nav.
Every snippet is one runnable file, docs/how-to/nested-loading-skeletons.tsx,
executed by packages/react/tests/how-to-nested-loading.test.tsx: the test drives
three deferred effects and asserts what is on screen at each stage, so the
documented result is the tested one.
The resolved selection was a fresh object on every router update, so any change
in the pending flags — a sibling chain starting to prepare — re-rendered the
selected view and everything under it.

The resolution now keeps its identity while the same view and component stay
selected, and both renderers sit behind a memo boundary, so unrelated updates
stop there. The outlet context value is memoized per view as well. Vue and Solid
already behaved this way; all three suites now assert it.
Collects the shapes the same pieces take for adjacent goals: filling an empty
slot, keeping the frame while only the inner part swaps, avoiding the remount a
component swap costs, streaming a ready child inside a parent skeleton, holding
a transition before the URL commits so the current page stays, redirecting
instead, splitting a level into a chunk, and grouping a layout.

The two claims that are not obvious from the API — an `<Outlet />` inside
`loading`, and `beforeNavigate` keeping the previous page on screen — are
covered by tests next to the guide's own.
Applies the review suggestion: the table already shows the sequence, and the
mechanism section states the not-found behaviour where it belongs.
Two guarantees the suite did not hold before, both found while investigating
layout remounts:

- switching pages inside one `withLayout` group keeps the layout instance, and
  moving to another group swaps it exactly once;
- the frame between a closing route and the opening one belongs to the target's
  `loading`, so the not-found screen never appears mid-navigation.

Both depend on the target declaring `loading`: that frame has nothing opened,
and without a fallback the routes view falls through to `otherwise` and takes
the layout down with it.
Closing the previous route and opening the next one is not atomic: for one
instant nothing in a routes view's list is opened. These specs pin what should
happen there even when no view declares `loading` — no `otherwise` flash, no
layout remount, no `Outlet` gap — plus the existing guarantees: a genuinely
unmatched URL still shows `otherwise` right away, and a first render with
nothing pending falls through to `closed` normally.

Solid gets two of its own: its fine-grained reactivity observes the route
closing and the next one becoming pending as separate ticks (React and Vue's
schedulers coalesce them), so it recovers from the gap rather than closing it
outright, at the cost of one extra layout remount. Both are asserted exactly,
so a future fix that closes the gap for Solid too will fail these and prompt
tightening them.
createRoutesView and Outlet resolve to nothing while the previous route is
closed and the next one hasn't opened yet, since router activation closes the
old route before the new one opens rather than swapping them atomically.
Before this, that gap fell straight through to closed/otherwise regardless of
whether the incoming view declared a loading fallback, flashing the not-found
screen and tearing down any withLayout group around it on every ordinary
navigation.

Resolution gains a third tier ahead of closed: while any listed route is still
pending, hold the most recently resolved view instead of falling through. The
hold never fires on a first render (nothing to hold yet) and never fires for a
genuinely unmatched URL (nothing pends for it), so otherwise still shows
immediately there.

Solid's fine-grained reactivity observes the previous route closing and the
next one becoming pending as two separate ticks rather than one coalesced
update (React and Vue's schedulers merge them), so the hold there recovers
from a transient otherwise frame instead of preventing it outright, at the
cost of one extra layout remount. Documented as a known characteristic rather
than silently papered over.
Updates the createRouteView/createRoutesView/Outlet reference pages across
React, Vue, and Solid with the new hold step in the resolution order, and adds
a note on Solid's fine-grained reactivity making it a partial rather than
complete fix there.
createRoutesView's resolution loop picks a closed fallback from any route
in the list that isn't open, with no check that the route has anything to
do with the current navigation. So a genuinely unmatched URL renders a
completely unrelated sibling's closed fallback instead of otherwise, as
long as that sibling declares one.
closed of a closed view is meant to fire for a route the app actually
navigated toward and found closed, not for an arbitrary sibling that
happens to not be open. createRoutesView now tracks whether anything in
its routes list has ever opened or been pending, and only lets a sibling's
closed fallback outrank a declared otherwise once that has happened —
otherwise a URL that never matched anything in the list rendered whichever
sibling's closed fallback came last in the array, instead of otherwise.

Outlet is unaffected: it has no otherwise of its own, so its closed
fallback keeps rendering immediately as before.
previous never clears once set, since resolve() only overwrites it on the
opened/loading/closed branches. So an abandoned view a user has already
navigated away from (routes view already correctly showing otherwise)
can resurface later when a completely unrelated route in the same list
starts pending, even though nothing connects the two.
previous only got overwritten on the opened/loading/closed branches, so
once a view was held it stayed cached forever — a page the user already
navigated away from (routes view correctly showing otherwise) could
resurface later if some unrelated route elsewhere in the list started
pending, since the stale previous was still there for the hold check to
find.

previous now always tracks resolve()'s own return value, matching React
and Vue. The close/open gap this hold exists for is still covered: the
hold check runs before previous is overwritten, so a genuinely pending
tick still finds and returns the frame held from the prior tick.
previous.current was written unconditionally in the component's render
body (not an effect), which React's rules for refs disallow: a render
pass can be invoked and discarded without ever committing (Strict Mode's
dev double-invoke, an interrupted concurrent render), leaving the ref
holding a value nothing on screen ever matched.

Moved the write into a useLayoutEffect keyed on the resolved value, so it
only lands after a render actually commits. No test-first commit here:
the hazard only shows up under real concurrent-mode preemption or a store
change landing between Strict Mode's two synchronous render invocations,
neither of which the current Vitest/Testing Library setup can trigger
without flaky, timing-dependent test code. The existing suite (53 tests,
unchanged) is the regression guard.
React and Solid both collapse the resolved view back to the previous
object reference when a recompute lands on the same view/component, so
router churn that doesn't change the selection doesn't retrigger
consumers. Vue's resolver has no equivalent: once the hold stops applying
(a chain's pending resolves back to false without opening), it hands out
a freshly built object even though it's the exact same closed fallback as
before, needlessly retriggering watchers.
React and Solid both hand out the previous object when a recompute lands
on the same view/component, so unrelated router churn does not retrigger
whatever consumes the resolution. Vue's resolver built a fresh object on
every branch, every recompute — so once the hold mechanism no longer
applied, it handed out a new object for content that hadn't actually
changed, needlessly retriggering watchers.
…rithm

The framework-agnostic core of the closed/loading resolution — the
priority loop, the transition hold, and the otherwise gate — was hand
copied across the React, Solid, and Vue bindings' resolve-route-view
modules. They already drifted apart once (the Solid stale-previous fix),
with nothing structural forcing a fix in one to land in the others.

Extracts that logic into a single pure, generic function with its own
unit test suite covering the resolution order, the transition hold, and
the otherwise gate in isolation. Not yet wired into any binding — that's
the next commit, so the existing binding test suites are the regression
guard that the switch doesn't change behavior.
Rewrote react/vue/solid's resolve-route-view modules to call the new
core function instead of running their own hand-copied algorithm.

- React makes resolveRouteView pure and defers committing its returned
  state to a useLayoutEffect (same rationale as the previous render-purity
  fix, now covering the algorithm's own state too instead of just the
  outer identity-collapse).
- Solid and Vue thread the returned state through a plain closure
  variable, matching how they already tracked it before the extraction.

Behavior is unchanged: the full existing suite across all four packages
(370 tests) passes without modification. AGENTS.md and a changeset
document the new shared module.
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