@ecopages/react-router 0.2.0-alpha.4 → 0.2.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/router.ts DELETED
@@ -1,348 +0,0 @@
1
- /**
2
- * SPA router with View Transitions API support for React applications.
3
- *
4
- * Intercepts link clicks for client-side navigation using the History API.
5
- * Supports animated page transitions via CSS view-transition pseudo-elements.
6
- *
7
- * @module router
8
- */
9
-
10
- import {
11
- useEffect,
12
- useState,
13
- useCallback,
14
- useMemo,
15
- useRef,
16
- createContext,
17
- useContext,
18
- startTransition,
19
- createElement,
20
- type ReactNode,
21
- type ComponentType,
22
- type FC,
23
- } from 'react';
24
- import { type EcoRouterOptions, DEFAULT_OPTIONS } from './types.ts';
25
- import { RouterContext } from './context.ts';
26
- import { type PageState, getInterceptDecision, loadPageModule, shouldInterceptClick } from './navigation.ts';
27
- import { morphHead } from './head-morpher.ts';
28
- import { applyViewTransitionNames } from './view-transition-utils.ts';
29
- import { manageScroll } from './manage-scroll.ts';
30
- import { saveScrollPositions, restoreScrollPositions } from './scroll-persist.ts';
31
- import type { EcoInjectedMeta } from '@ecopages/core';
32
-
33
- type PageContextValue = PageState | null;
34
-
35
- const PageContext = createContext<PageContextValue>(null);
36
-
37
- const PersistLayoutsContext = createContext<boolean>(false);
38
-
39
- function getLayoutFromPage(Page: ComponentType<unknown>): ComponentType | undefined {
40
- const config = (Page as ComponentType & { config?: { layout?: ComponentType } }).config;
41
- return config?.layout;
42
- }
43
-
44
- /**
45
- * Props for the {@link EcoRouter} component.
46
- */
47
- export interface EcoRouterProps {
48
- /** Page component to render */
49
- page: ComponentType<unknown>;
50
- /** Props passed to the page component */
51
- pageProps: Record<string, unknown>;
52
- /** Router configuration */
53
- options?: EcoRouterOptions;
54
- /** Children (should contain {@link PageContent}) */
55
- children: ReactNode;
56
- }
57
-
58
- /**
59
- * Cache for layout components to ensure same reference across navigations.
60
- * When different pages import the same layout, they get different function
61
- * references. This cache ensures we reuse the first one seen for each displayName.
62
- *
63
- * Stored on window to persist across module reloads during HMR/SPA navigation.
64
- */
65
- function getLayoutCache(): Map<string, ComponentType> {
66
- if (typeof window === 'undefined') {
67
- return new Map();
68
- }
69
- const win = window as typeof window & { __ecoLayoutCache?: Map<string, ComponentType> };
70
- if (!win.__ecoLayoutCache) {
71
- win.__ecoLayoutCache = new Map();
72
- }
73
- return win.__ecoLayoutCache;
74
- }
75
-
76
- function normalizeLayoutKey(value: string): string {
77
- const trimmed = value.trim();
78
- if (!trimmed) return 'layout';
79
-
80
- try {
81
- const asUrl = new URL(trimmed);
82
- return asUrl.pathname.replace(/\/$/, '') || 'layout';
83
- } catch {
84
- return trimmed.split('#')[0]?.split('?')[0]?.replace(/\/$/, '') || 'layout';
85
- }
86
- }
87
-
88
- /**
89
- * Clears the layout cache. Called during HMR to ensure fresh layouts are used.
90
- */
91
- export function clearLayoutCache(): void {
92
- getLayoutCache().clear();
93
- }
94
-
95
- /**
96
- * Renders the current page with its layout.
97
- *
98
- * Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
99
- * shared layouts remain mounted across navigations.
100
- *
101
- * @example
102
- * ```tsx
103
- * <EcoRouter page={Page} pageProps={props}>
104
- * <PageContent />
105
- * </EcoRouter>
106
- * ```
107
- */
108
- export const PageContent: FC = () => {
109
- const pageContext = useContext(PageContext);
110
- const persistLayouts = useContext(PersistLayoutsContext);
111
-
112
- if (!pageContext) {
113
- if (import.meta.env.NODE_ENV !== 'production') {
114
- console.warn('[EcoRouter] PageContent used outside of EcoRouter');
115
- }
116
- return null;
117
- }
118
-
119
- const { Component: Page, props } = pageContext;
120
- const Layout = getLayoutFromPage(Page);
121
- const pageElement = createElement(Page, props);
122
-
123
- if (!Layout) {
124
- return pageElement;
125
- }
126
-
127
- if (persistLayouts) {
128
- const layoutCache = getLayoutCache();
129
- const layoutConfig = (Layout as ComponentType & { config?: { __eco?: EcoInjectedMeta } }).config;
130
- const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || 'layout';
131
- const layoutKey = normalizeLayoutKey(layoutKeyRaw);
132
-
133
- if (!layoutCache.has(layoutKey)) {
134
- layoutCache.set(layoutKey, Layout);
135
- }
136
- const CachedLayout = layoutCache.get(layoutKey)!;
137
-
138
- return createElement(CachedLayout, { key: layoutKey }, pageElement);
139
- }
140
-
141
- return createElement(Layout, null, pageElement);
142
- };
143
-
144
- function createDeferred<T>() {
145
- let resolve!: (value: T) => void;
146
- const promise = new Promise<T>((res) => {
147
- resolve = res;
148
- });
149
- return { promise, resolve };
150
- }
151
-
152
- function useHmrReload(navigate: (url: string) => Promise<void>) {
153
- useEffect(() => {
154
- if (typeof window === 'undefined') return;
155
- if (import.meta.env?.MODE === 'production' || import.meta.env?.PROD) return;
156
-
157
- const windowWithHmr = window as typeof window & {
158
- __ecopages_reload_current_page__?: (options?: { clearCache?: boolean }) => Promise<void>;
159
- };
160
-
161
- windowWithHmr.__ecopages_reload_current_page__ = async (options?: { clearCache?: boolean }) => {
162
- if (options?.clearCache) {
163
- clearLayoutCache();
164
- }
165
- const currentUrl = window.location.pathname + window.location.search;
166
- await navigate(currentUrl);
167
- };
168
-
169
- return () => {
170
- windowWithHmr.__ecopages_reload_current_page__ = undefined;
171
- };
172
- }, [navigate]);
173
- }
174
-
175
- /**
176
- * Root router providing SPA navigation with View Transitions.
177
- *
178
- * Coordinates navigation flow:
179
- * 1. Intercepts link clicks and popstate events
180
- * 2. Loads page module and updates document head
181
- * 3. Triggers View Transition (if supported)
182
- * 4. Updates React state inside transition callback
183
- * 5. Resolves deferred promise after render
184
- * 6. Browser captures new DOM snapshot
185
- *
186
- * @example
187
- * ```tsx
188
- * <EcoRouter
189
- * page={CurrentPage}
190
- * pageProps={pageProps}
191
- * options={{ persistLayouts: true }}
192
- * >
193
- * <PageContent />
194
- * </EcoRouter>
195
- * ```
196
- *
197
- * @example Shared element transitions
198
- * ```tsx
199
- * // List page
200
- * <img data-view-transition={`hero-${id}`} src={src} />
201
- *
202
- * // Detail page
203
- * <img data-view-transition={`hero-${id}`} src={src} />
204
- * ```
205
- */
206
- export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOptions, children }: EcoRouterProps) => {
207
- const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
208
- const [currentPage, setCurrentPage] = useState<PageState>({ Component: page, props: pageProps });
209
- const [isNavigating, setIsNavigating] = useState(false);
210
- const [pendingPage, setPendingPage] = useState<PageState | null>(null);
211
- const renderDfd = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null);
212
- const pendingScrollRestoreRef = useRef<{ url: string; isPopState: boolean } | null>(null);
213
- const previousUrlRef = useRef<string>(typeof window !== 'undefined' ? window.location.href : '');
214
-
215
- useEffect(() => {
216
- setCurrentPage({ Component: page, props: pageProps });
217
- }, [page, pageProps]);
218
-
219
- useEffect(() => {
220
- applyViewTransitionNames();
221
- }, [currentPage]);
222
-
223
- useEffect(() => {
224
- if (pendingPage && currentPage.Component === pendingPage.Component && renderDfd.current) {
225
- renderDfd.current.resolve();
226
- renderDfd.current = null;
227
- setPendingPage(null);
228
- }
229
- }, [currentPage, pendingPage]);
230
-
231
- useEffect(() => {
232
- if (typeof window === 'undefined') return;
233
-
234
- const url = new URL(window.location.href);
235
- const previousUrl = new URL(previousUrlRef.current);
236
-
237
- if (url.href !== previousUrl.href) {
238
- manageScroll(url, previousUrl, {
239
- scrollBehavior: options.scrollBehavior,
240
- smoothScroll: options.smoothScroll,
241
- });
242
- previousUrlRef.current = url.href;
243
- }
244
-
245
- if (pendingScrollRestoreRef.current) {
246
- const { url: targetUrl, isPopState } = pendingScrollRestoreRef.current;
247
- restoreScrollPositions(targetUrl, isPopState);
248
- pendingScrollRestoreRef.current = null;
249
- }
250
- }, [currentPage, options.scrollBehavior, options.smoothScroll]);
251
-
252
- const navigate = useCallback(
253
- async (url: string, isPopState = false) => {
254
- setIsNavigating(true);
255
- const result = await loadPageModule(url);
256
-
257
- if (result) {
258
- const { Component, props, doc, finalPath } = result;
259
- const nextPage = { Component, props };
260
- const cleanupHead = await morphHead(doc);
261
- applyViewTransitionNames();
262
-
263
- if (finalPath !== url) {
264
- window.history.replaceState(null, '', finalPath);
265
- }
266
-
267
- saveScrollPositions();
268
- pendingScrollRestoreRef.current = { url, isPopState };
269
-
270
- if (options.viewTransitions && document.startViewTransition) {
271
- renderDfd.current = createDeferred<void>();
272
- setPendingPage(nextPage);
273
-
274
- document.startViewTransition(async () => {
275
- startTransition(() => {
276
- setCurrentPage(nextPage);
277
- });
278
- await renderDfd.current?.promise;
279
- cleanupHead();
280
- applyViewTransitionNames();
281
- });
282
- } else {
283
- setCurrentPage(nextPage);
284
- cleanupHead();
285
- applyViewTransitionNames();
286
- }
287
- } else {
288
- if (options.debug) {
289
- console.error('[EcoRouter] Falling back to full page navigation:', url);
290
- }
291
- window.location.href = url;
292
- }
293
- setIsNavigating(false);
294
- },
295
- [options.viewTransitions, options.debug],
296
- );
297
-
298
- useEffect(() => {
299
- const handleClick = (event: MouseEvent) => {
300
- const link = (event.target as Element).closest(options.linkSelector) as HTMLAnchorElement | null;
301
- if (!link) return;
302
- if (!shouldInterceptClick(event, link, options)) {
303
- if (options.debug) {
304
- const decision = getInterceptDecision(event, link, options);
305
- if (!decision.shouldIntercept) {
306
- console.debug('[EcoRouter] Not intercepting link click:', decision.reason, link.href);
307
- }
308
- }
309
- return;
310
- }
311
-
312
- event.preventDefault();
313
- const href = link.getAttribute('href')!;
314
- const url = new URL(href, window.location.origin);
315
-
316
- if (options.debug) {
317
- console.debug('[EcoRouter] Intercepting navigation:', url.pathname + url.search);
318
- }
319
-
320
- window.history.pushState(null, '', url.href);
321
- navigate(url.pathname + url.search);
322
- };
323
-
324
- const handlePopState = () => {
325
- navigate(window.location.pathname + window.location.search, true);
326
- };
327
-
328
- document.addEventListener('click', handleClick);
329
- window.addEventListener('popstate', handlePopState);
330
-
331
- return () => {
332
- document.removeEventListener('click', handleClick);
333
- window.removeEventListener('popstate', handlePopState);
334
- };
335
- }, [navigate, options]);
336
-
337
- useHmrReload(navigate);
338
-
339
- return createElement(
340
- RouterContext.Provider,
341
- { value: { navigate, isNavigating } },
342
- createElement(
343
- PersistLayoutsContext.Provider,
344
- { value: options.persistLayouts },
345
- createElement(PageContext.Provider, { value: currentPage }, children),
346
- ),
347
- );
348
- };
@@ -1,96 +0,0 @@
1
- /**
2
- * Scroll position persistence for elements marked with `data-eco-persist="scroll"`.
3
- *
4
- * Handles two navigation scenarios:
5
- * - **Forward navigation**: Preserves current scroll positions for shared layout elements
6
- * - **Back navigation**: Restores positions from when the page was last visited
7
- *
8
- * @module scroll-persist
9
- */
10
-
11
- const PERSIST_SELECTOR = '[data-eco-persist="scroll"]';
12
-
13
- type ScrollPosition = { top: number; left: number };
14
- type ScrollMap = Map<string, ScrollPosition>;
15
- type UrlScrollStore = Map<string, ScrollMap>;
16
-
17
- const urlScrollStore: UrlScrollStore = new Map();
18
- let currentScrollSnapshot: ScrollMap | null = null;
19
-
20
- function getElementKey(el: Element): string | null {
21
- return el.id || el.getAttribute('data-testid') || null;
22
- }
23
-
24
- function captureScrollPositions(): ScrollMap {
25
- const positions = new Map<string, ScrollPosition>();
26
- document.querySelectorAll(PERSIST_SELECTOR).forEach((el) => {
27
- const key = getElementKey(el);
28
- if (key) {
29
- positions.set(key, { top: el.scrollTop, left: el.scrollLeft });
30
- }
31
- });
32
- return positions;
33
- }
34
-
35
- /**
36
- * Captures and stores scroll positions before navigation.
37
- *
38
- * Saves positions to both:
39
- * - URL-keyed store (for back navigation restoration)
40
- * - Current snapshot (for forward navigation preservation)
41
- */
42
- export function saveScrollPositions(): void {
43
- const url = `${window.location.pathname}${window.location.search}`;
44
- const positions = captureScrollPositions();
45
-
46
- if (positions.size > 0) {
47
- urlScrollStore.set(url, positions);
48
- }
49
- currentScrollSnapshot = positions;
50
- }
51
-
52
- /**
53
- * Restores scroll positions after React render completes.
54
- *
55
- * Uses double `requestAnimationFrame` to ensure DOM is fully painted.
56
- *
57
- * @param targetUrl - The URL being navigated to
58
- * @param isPopState - True for back/forward navigation, false for link clicks
59
- */
60
- export function restoreScrollPositions(targetUrl: string, isPopState: boolean): void {
61
- const positions = isPopState ? urlScrollStore.get(targetUrl) : currentScrollSnapshot;
62
-
63
- if (!positions || positions.size === 0) {
64
- currentScrollSnapshot = null;
65
- return;
66
- }
67
-
68
- requestAnimationFrame(() => {
69
- requestAnimationFrame(() => {
70
- document.querySelectorAll(PERSIST_SELECTOR).forEach((el) => {
71
- const key = getElementKey(el);
72
- if (key && positions.has(key)) {
73
- const pos = positions.get(key)!;
74
- el.scrollTop = pos.top;
75
- el.scrollLeft = pos.left;
76
- }
77
- });
78
- currentScrollSnapshot = null;
79
- });
80
- });
81
- }
82
-
83
- /**
84
- * Returns stored scroll positions for a URL without applying them.
85
- */
86
- export function getScrollPositions(url: string): ScrollMap | undefined {
87
- return urlScrollStore.get(url);
88
- }
89
-
90
- /**
91
- * Clears all stored scroll positions.
92
- */
93
- export function clearScrollPositions(): void {
94
- urlScrollStore.clear();
95
- currentScrollSnapshot = null;
96
- }
package/src/types.ts DELETED
@@ -1,64 +0,0 @@
1
- /**
2
- * Configuration options for the EcoRouter
3
- */
4
- export interface EcoRouterOptions {
5
- /**
6
- * CSS selector for links to intercept
7
- * @default 'a[href]'
8
- */
9
- linkSelector?: string;
10
-
11
- /**
12
- * Attribute that forces a full page reload when present on a link
13
- * @default 'data-eco-reload'
14
- */
15
- reloadAttribute?: string;
16
-
17
- /**
18
- * Whether to use the View Transitions API for page animations.
19
- * When enabled and supported, page transitions animate using CSS view-transition pseudo-elements.
20
- * Falls back to instant swap if not supported by the browser.
21
- * @default true
22
- */
23
- viewTransitions?: boolean;
24
-
25
- /**
26
- * Enable debug logging to console
27
- * @default false
28
- */
29
- debug?: boolean;
30
-
31
- /**
32
- * Scroll behavior after navigation:
33
- * - 'top': Always scroll to top (default)
34
- * - 'preserve': Keep current scroll position
35
- * - 'auto': Scroll to top only when pathname changes
36
- */
37
- scrollBehavior?: 'top' | 'preserve' | 'auto';
38
-
39
- /**
40
- * Whether to use smooth scrolling during navigation.
41
- * If true, uses 'smooth' behavior. If false, uses 'instant' behavior.
42
- * @default false
43
- */
44
- smoothScroll?: boolean;
45
-
46
- /**
47
- * Keep layouts mounted between navigations.
48
- * When enabled, if consecutive pages share the same layout component,
49
- * the layout stays mounted and only the page content re-renders.
50
- * This preserves layout state including scroll positions.
51
- * @default true
52
- */
53
- persistLayouts?: boolean;
54
- }
55
-
56
- export const DEFAULT_OPTIONS: Required<EcoRouterOptions> = {
57
- linkSelector: 'a[href]',
58
- reloadAttribute: 'data-eco-reload',
59
- viewTransitions: true,
60
- debug: false,
61
- scrollBehavior: 'top',
62
- smoothScroll: false,
63
- persistLayouts: true,
64
- };
@@ -1,30 +0,0 @@
1
- /**
2
- * Manages View Transition API integration
3
- * @module
4
- */
5
-
6
- type ViewTransitionDocument = Document & {
7
- startViewTransition: (callback: () => void | Promise<void>) => {
8
- finished: Promise<void>;
9
- ready: Promise<void>;
10
- updateCallbackDone: Promise<void>;
11
- skipTransition(): void;
12
- };
13
- };
14
-
15
- function isViewTransitionSupported(): boolean {
16
- return 'startViewTransition' in document;
17
- }
18
-
19
- export async function withViewTransition(callback: () => void): Promise<void> {
20
- if (!isViewTransitionSupported()) {
21
- callback();
22
- return;
23
- }
24
-
25
- const transition = (document as ViewTransitionDocument).startViewTransition(() => {
26
- callback();
27
- });
28
-
29
- await transition.finished;
30
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * View transition utilities for applying transition names from data attributes.
3
- * @module
4
- */
5
-
6
- const VIEW_TRANSITION_ATTR = 'data-view-transition';
7
- const VIEW_TRANSITION_ANIMATE_ATTR = 'data-view-transition-animate';
8
- const VIEW_TRANSITION_DURATION_ATTR = 'data-view-transition-duration';
9
-
10
- /**
11
- * Applies view-transition-name CSS property to elements with data-view-transition attribute.
12
- * Also handles data-view-transition-animate="morph" (default) logic and custom duration.
13
- */
14
- export function applyViewTransitionNames(): void {
15
- const elements = document.querySelectorAll(`[${VIEW_TRANSITION_ATTR}]`);
16
- const morphNames: string[] = [];
17
- const customDurations: { name: string; duration: string }[] = [];
18
-
19
- elements.forEach((el) => {
20
- const name = el.getAttribute(VIEW_TRANSITION_ATTR);
21
- if (name) {
22
- (el as HTMLElement).style.viewTransitionName = name;
23
- /**
24
- * By default, we apply a clean geometric morph (no cross-fade/ghosting).
25
- * The 'fade' value is reserved for opting out of this behavior.
26
- */
27
- const animate = el.getAttribute(VIEW_TRANSITION_ANIMATE_ATTR);
28
- if (animate !== 'fade') {
29
- morphNames.push(name);
30
- }
31
-
32
- const duration = el.getAttribute(VIEW_TRANSITION_DURATION_ATTR);
33
- if (duration) {
34
- customDurations.push({ name, duration });
35
- }
36
- }
37
- });
38
-
39
- if (morphNames.length > 0 || customDurations.length > 0) {
40
- injectDynamicStyles(morphNames, customDurations);
41
- }
42
- }
43
-
44
- /**
45
- * Injects dynamic CSS to hide old snapshots and apply custom durations.
46
- */
47
- function injectDynamicStyles(morphNames: string[], customDurations: { name: string; duration: string }[]) {
48
- let styleEl = document.getElementById('eco-vt-dynamic-styles');
49
- if (!styleEl) {
50
- styleEl = document.createElement('style');
51
- styleEl.id = 'eco-vt-dynamic-styles';
52
- /**
53
- * Persistence is required to prevent the head-morpher from removing this style tag during navigation.
54
- */
55
- styleEl.setAttribute('data-eco-persist', '');
56
- document.head.appendChild(styleEl);
57
- }
58
-
59
- const morphCss = morphNames
60
- .map(
61
- (name) => `
62
- ::view-transition-old(${name}) { display: none !important; }
63
- ::view-transition-new(${name}) { animation: none !important; opacity: 1 !important; }
64
- `,
65
- )
66
- .join('\n');
67
-
68
- const durationCss = customDurations
69
- .map(
70
- ({ name, duration }) => `
71
- ::view-transition-group(${name}) { animation-duration: ${duration} !important; }
72
- `,
73
- )
74
- .join('\n');
75
-
76
- styleEl.textContent = morphCss + '\n' + durationCss;
77
- }
78
-
79
- /**
80
- * Clears view-transition-name CSS property from all elements.
81
- */
82
- export function clearViewTransitionNames(): void {
83
- const elements = document.querySelectorAll(`[${VIEW_TRANSITION_ATTR}]`);
84
- elements.forEach((el) => {
85
- (el as HTMLElement).style.viewTransitionName = '';
86
- });
87
-
88
- /**
89
- * Cleanup dynamic styles to ensure a clean slate for the next transition.
90
- */
91
- const styleEl = document.getElementById('eco-vt-dynamic-styles');
92
- if (styleEl) {
93
- styleEl.textContent = '';
94
- }
95
- }