feat(react,vue,solid): add closed and loading to route views - #106
Draft
sergeysova wants to merge 24 commits into
Draft
feat(react,vue,solid): add closed and loading to route views#106sergeysova wants to merge 24 commits into
sergeysova wants to merge 24 commits into
Conversation
|
|
🦋 Changeset detectedLatest commit: 1d11204 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
✅ Deploy Preview for effector-router ready!
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
force-pushed
the
claude/createroute-otherwise-loading-h1g5nw
branch
from
July 29, 2026 11:29
8b8e72d to
f816d2b
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings back the
atomic-routerskeleton pattern (nestedCreateRouteViewwithotherwise) in the shape this router uses — as two new properties ofcreateRouteView/createLazyRouteViewin the React, Vue, and Solid bindings:closed— rendered while the route is not opened;loading— rendered while the route is pending (beforeOpeneffects, achainRoutepreparation) and while a lazy chunk loads.A view now reads as its three states:
viewwhen opened,loadingwhile pending,closedwhile closed.createRoutesView({ otherwise })keeps its name and its different meaning — nothing matched the URL.createRoutesViewandOutletstill render a single view. Resolution order:loadingof a pending view;closedof a closed view — only once something in the list has genuinely opened or been pending at least once, so it can't permanently shadowotherwisefor a URL that never matched anything in the list;otherwiseprop ofcreateRoutesView(nullinside anOutlet).Because an opened view always wins,
loadingnever replaces a page already on screen. Fallbacks are wrapped by the view'slayoutand by itswithLayoutgroup, so a grouped layout stays mounted while a nested chain resolves — the parent view keeps rendering while itsOutletshows 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'sfallbackonly covered the chunk request, so a pending route fell through to the not-found screen.fallbackis deprecated in favour ofloading, 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 toclosed/otherwiseon every ordinary navigation, not just ones with a declaredloading— flashing the not-found screen and tearing down anywithLayoutgroup around it. The hold applies only while something is pending; a genuinely unmatched URL still showsotherwiseimmediately, 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
otherwiseframe. 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:
otherwiseno longer gets shadowed by an unrelatedclosed. Any route in the list with aclosedfallback used to win as soon as nothing was open, even for a URL that never matched anything in the list.useLayoutEffectinstead of the render body, since a render pass can be discarded without committing (Strict Mode's double-invoke, an interrupted concurrent render).resolveRouteView, exported (@internal) from@effector/routerwith its own unit test suite; each binding is left with only its own reactive glue around it.Other changes
createRoutesView/Outletno longer re-render their surrounding tree on every store tick that doesn't change the resolved view — memoized resolution, aReact.memoboundary, and a memoized outlet-context value.docs/how-to/nested-loading-skeletons.md— a tested, code-backed React example for nestedloadingskeletons, plus a "Variations" chapter of short recipes for adjacent goals (e.g. skeleton only on first load, layout-only holds, error boundaries alongsideclosed).Each binding's
resolve-route-viewmodule wraps the shared core algorithm with reactive glue and owns the fallback wrapping;createRoutesViewandOutletnow render through it.useOpenedViewskeeps its current public semantics (opened views only).React Native re-exports the React factories, so the props type-check there; the
@react-navigationnavigators mount every route view as a screen and renderviewonly — documented in the React Native reference.Type of change
Checklist
pnpm buildpassespnpm typecheckpasses (pluspnpm verify:typecheck-standalone,pnpm :docs typecheck,pnpm :docs check-links)pnpm testpasses 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 lintpassesminor/patchacross@effector/router,@effector/router-react,@effector/router-vue,@effector/router-solid)