@ecopages/react-router 0.2.0-alpha.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +212 -0
  4. package/browser.d.ts +13 -0
  5. package/browser.js +11 -0
  6. package/browser.ts +17 -0
  7. package/package.json +42 -0
  8. package/src/adapter.d.ts +28 -0
  9. package/src/adapter.js +22 -0
  10. package/src/adapter.ts +48 -0
  11. package/src/context.d.ts +16 -0
  12. package/src/context.js +11 -0
  13. package/src/context.ts +25 -0
  14. package/src/head-morpher.d.ts +15 -0
  15. package/src/head-morpher.js +94 -0
  16. package/src/head-morpher.ts +170 -0
  17. package/src/index.d.ts +14 -0
  18. package/src/index.js +13 -0
  19. package/src/index.ts +21 -0
  20. package/src/manage-scroll.d.ts +17 -0
  21. package/src/manage-scroll.js +25 -0
  22. package/src/manage-scroll.ts +47 -0
  23. package/src/navigation.d.ts +65 -0
  24. package/src/navigation.js +120 -0
  25. package/src/navigation.ts +247 -0
  26. package/src/props-script.d.ts +11 -0
  27. package/src/props-script.js +11 -0
  28. package/src/props-script.ts +19 -0
  29. package/src/router.d.ts +73 -0
  30. package/src/router.js +225 -0
  31. package/src/router.ts +348 -0
  32. package/src/scroll-persist.d.ts +40 -0
  33. package/src/scroll-persist.js +57 -0
  34. package/src/scroll-persist.ts +96 -0
  35. package/src/styles.css +200 -0
  36. package/src/types.d.ts +49 -0
  37. package/src/types.js +12 -0
  38. package/src/types.ts +64 -0
  39. package/src/view-transition-manager.d.ts +5 -0
  40. package/src/view-transition-manager.js +16 -0
  41. package/src/view-transition-manager.ts +30 -0
  42. package/src/view-transition-utils.d.ts +13 -0
  43. package/src/view-transition-utils.js +60 -0
  44. package/src/view-transition-utils.ts +95 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Navigation utilities for fetching and parsing page content.
3
+ * @module
4
+ */
5
+
6
+ /// <reference types="@ecopages/core/declarations" />
7
+
8
+ import { type ComponentType } from 'react';
9
+ import type { EcoRouterOptions } from './types.ts';
10
+
11
+ export type PageState = {
12
+ Component: ComponentType<any>;
13
+ props: Record<string, any>;
14
+ };
15
+
16
+ export type InterceptDecision =
17
+ | { shouldIntercept: true }
18
+ | {
19
+ shouldIntercept: false;
20
+ reason:
21
+ | 'modified-click'
22
+ | 'non-left-click'
23
+ | 'external-target'
24
+ | 'explicit-reload'
25
+ | 'download'
26
+ | 'invalid-href'
27
+ | 'cross-origin';
28
+ };
29
+
30
+ /**
31
+ * Determines whether a link click should be intercepted for client-side navigation.
32
+ *
33
+ * Standard SPA navigation rules:
34
+ * - Modified clicks (Cmd/Ctrl/Shift/Alt) open in new tab
35
+ * - Non-left clicks use default browser behavior
36
+ * - External targets, downloads, and cross-origin links navigate normally
37
+ *
38
+ * @returns Object indicating whether to intercept and the reason if not
39
+ */
40
+ export function getInterceptDecision(
41
+ event: MouseEvent,
42
+ link: HTMLAnchorElement,
43
+ options: Required<EcoRouterOptions>,
44
+ ): InterceptDecision {
45
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
46
+ return { shouldIntercept: false, reason: 'modified-click' };
47
+ }
48
+ if (event.button !== 0) return { shouldIntercept: false, reason: 'non-left-click' };
49
+
50
+ const target = link.getAttribute('target');
51
+ if (target && target !== '_self') return { shouldIntercept: false, reason: 'external-target' };
52
+
53
+ if (link.hasAttribute(options.reloadAttribute)) return { shouldIntercept: false, reason: 'explicit-reload' };
54
+ if (link.hasAttribute('download')) return { shouldIntercept: false, reason: 'download' };
55
+
56
+ const href = link.getAttribute('href');
57
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
58
+ return { shouldIntercept: false, reason: 'invalid-href' };
59
+ }
60
+
61
+ const url = new URL(href, window.location.origin);
62
+ if (url.origin !== window.location.origin) return { shouldIntercept: false, reason: 'cross-origin' };
63
+
64
+ return { shouldIntercept: true };
65
+ }
66
+
67
+ /**
68
+ * Extracts component module URL from window.__ECO_PAGE__.
69
+ * For current document, returns the module path set by hydration script.
70
+ * For fetched documents, parses the hydration script to extract the module path.
71
+ */
72
+ function extractComponentUrlFromMarker(doc: Document): string | null {
73
+ if (doc === document && window.__ECO_PAGE__?.module) {
74
+ return window.__ECO_PAGE__.module;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Matches default import: `import Content from './Content'`
81
+ * Used to extract module path from hydration script for fetched documents.
82
+ */
83
+ const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
84
+
85
+ /**
86
+ * Matches namespace import: `import * as Content from './Content'`
87
+ * Used for MDX components. Also handles minified: `import*as Content from'./Content'`
88
+ */
89
+ const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
90
+
91
+ /**
92
+ * Extracts import path from hydration script code using regex.
93
+ * Used for fetched documents. Less reliable due to minification.
94
+ */
95
+ function extractModulePathFromCode(code: string): string | null {
96
+ const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
97
+ const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
98
+ return (defaultMatch || namespaceMatch)?.[2] ?? null;
99
+ }
100
+
101
+ /**
102
+ * Extracts serialized page props from window.__ECO_PAGE__ or fetched document.
103
+ * For current document, returns props set by hydration script.
104
+ * For fetched documents, parses the JSON script tag directly.
105
+ */
106
+ export function extractProps(doc: Document): Record<string, any> {
107
+ if (doc === document && window.__ECO_PAGE__?.props) {
108
+ return window.__ECO_PAGE__.props;
109
+ }
110
+
111
+ const propsScript = doc.getElementById('__ECO_PAGE_DATA__');
112
+ if (propsScript?.textContent) {
113
+ try {
114
+ return JSON.parse(propsScript.textContent);
115
+ } catch (e) {
116
+ console.error('[EcoRouter] Failed to parse props:', e);
117
+ return {};
118
+ }
119
+ }
120
+
121
+ return {};
122
+ }
123
+
124
+ /**
125
+ * Adds cache-busting timestamp for HMR in development.
126
+ *
127
+ * Prevents loading stale cached modules when navigating to previously visited pages.
128
+ * Disabled in production where filenames have content hashes.
129
+ */
130
+ function addCacheBuster(url: string): string {
131
+ if (import.meta.env?.MODE === 'production' || import.meta.env?.PROD) {
132
+ return url;
133
+ }
134
+ const separator = url.includes('?') ? '&' : '?';
135
+ return `${url}${separator}t=${Date.now()}`;
136
+ }
137
+
138
+ /**
139
+ * Extracts component module URL using multi-tier strategy.
140
+ *
141
+ * 1. Read from window.__ECO_PAGE__.module (for current document)
142
+ * 2. Parse inline hydration script with regex (for fetched documents)
143
+ * 3. Fetch and parse external hydration script (final fallback)
144
+ *
145
+ * Regex parsing is less reliable due to minification.
146
+ */
147
+ export async function extractComponentUrl(doc: Document): Promise<string | null> {
148
+ const markerUrl = extractComponentUrlFromMarker(doc);
149
+ if (markerUrl) return markerUrl;
150
+
151
+ const scripts = Array.from(doc.querySelectorAll('script'));
152
+
153
+ const inlineHydrationScript = scripts.find(
154
+ (s) =>
155
+ !s.src &&
156
+ !!s.textContent &&
157
+ s.textContent.includes('__ECO_PAGE__') &&
158
+ s.textContent.includes('hydrateRoot') &&
159
+ s.textContent.includes('import'),
160
+ );
161
+
162
+ if (inlineHydrationScript?.textContent) {
163
+ return extractModulePathFromCode(inlineHydrationScript.textContent);
164
+ }
165
+
166
+ const hydrationScript = scripts.find((s) => s.src?.includes('hydration.js') && s.src?.includes('ecopages-react'));
167
+ if (!hydrationScript?.src) return null;
168
+
169
+ try {
170
+ const scriptUrl = addCacheBuster(hydrationScript.src);
171
+ const res = await fetch(scriptUrl);
172
+ const code = await res.text();
173
+ return extractModulePathFromCode(code);
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Fetches and parses a page, returning its component, props, and document.
181
+ *
182
+ * Flow: Fetch HTML → Parse → Extract props → Extract component URL → Import module
183
+ *
184
+ * Handles multiple export patterns (Content, default.Content, default) for different
185
+ * integration setups. Does NOT update DOM - caller applies changes.
186
+ *
187
+ * @param url - The URL to load
188
+ * @returns Object with Component, props, doc, and finalPath, or null on error
189
+ */
190
+ export async function loadPageModule(
191
+ url: string,
192
+ ): Promise<{ Component: ComponentType<any>; props: Record<string, any>; doc: Document; finalPath: string } | null> {
193
+ try {
194
+ const res = await fetch(url);
195
+ const html = await res.text();
196
+
197
+ const finalUrl = new URL(res.url || url, window.location.origin);
198
+ const finalPath = finalUrl.pathname + finalUrl.search;
199
+
200
+ const doc = new DOMParser().parseFromString(html, 'text/html');
201
+
202
+ const props = extractProps(doc);
203
+ const componentUrl = await extractComponentUrl(doc);
204
+
205
+ if (!componentUrl) {
206
+ console.error('[EcoRouter] Could not find component URL');
207
+ return null;
208
+ }
209
+
210
+ const moduleUrl = addCacheBuster(componentUrl);
211
+ const module = await import(/* @vite-ignore */ moduleUrl);
212
+ const rawComponent = module.Content || module.default?.Content || module.default;
213
+
214
+ const config = module.config || rawComponent?.config;
215
+
216
+ if (!rawComponent) {
217
+ console.error('[EcoRouter] No component found in module');
218
+ return null;
219
+ }
220
+
221
+ if (config && !rawComponent.config) {
222
+ rawComponent.config = config;
223
+ }
224
+
225
+ window.__ECO_PAGE__ = {
226
+ module: componentUrl,
227
+ props,
228
+ };
229
+
230
+ return { Component: rawComponent, props, doc, finalPath };
231
+ } catch (e) {
232
+ console.error('[EcoRouter] Navigation failed:', e);
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Convenience wrapper around getInterceptDecision that returns a boolean.
239
+ * Use getInterceptDecision directly when you need the reason for debugging.
240
+ */
241
+ export function shouldInterceptClick(
242
+ event: MouseEvent,
243
+ link: HTMLAnchorElement,
244
+ options: Required<EcoRouterOptions>,
245
+ ): boolean {
246
+ return getInterceptDecision(event, link, options).shouldIntercept;
247
+ }
@@ -0,0 +1,11 @@
1
+ import { type FC } from 'react';
2
+ export interface EcoPropsScriptProps {
3
+ /** The page props to serialize for client-side hydration */
4
+ data: Record<string, any>;
5
+ }
6
+ /**
7
+ * Serializes page props as JSON for SPA navigation.
8
+ * The hydration script reads this and sets window.__ECO_PAGE__.
9
+ * Using application/json allows direct parsing without regex.
10
+ */
11
+ export declare const EcoPropsScript: FC<EcoPropsScriptProps>;
@@ -0,0 +1,11 @@
1
+ import { createElement } from "react";
2
+ const EcoPropsScript = ({ data }) => {
3
+ return createElement("script", {
4
+ id: "__ECO_PAGE_DATA__",
5
+ type: "application/json",
6
+ dangerouslySetInnerHTML: { __html: JSON.stringify(data || {}) }
7
+ });
8
+ };
9
+ export {
10
+ EcoPropsScript
11
+ };
@@ -0,0 +1,19 @@
1
+ import { createElement, type FC } from 'react';
2
+
3
+ export interface EcoPropsScriptProps {
4
+ /** The page props to serialize for client-side hydration */
5
+ data: Record<string, any>;
6
+ }
7
+
8
+ /**
9
+ * Serializes page props as JSON for SPA navigation.
10
+ * The hydration script reads this and sets window.__ECO_PAGE__.
11
+ * Using application/json allows direct parsing without regex.
12
+ */
13
+ export const EcoPropsScript: FC<EcoPropsScriptProps> = ({ data }) => {
14
+ return createElement('script', {
15
+ id: '__ECO_PAGE_DATA__',
16
+ type: 'application/json',
17
+ dangerouslySetInnerHTML: { __html: JSON.stringify(data || {}) },
18
+ });
19
+ };
@@ -0,0 +1,73 @@
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
+ import { type ReactNode, type ComponentType, type FC } from 'react';
10
+ import { type EcoRouterOptions } from './types.js';
11
+ /**
12
+ * Props for the {@link EcoRouter} component.
13
+ */
14
+ export interface EcoRouterProps {
15
+ /** Page component to render */
16
+ page: ComponentType<unknown>;
17
+ /** Props passed to the page component */
18
+ pageProps: Record<string, unknown>;
19
+ /** Router configuration */
20
+ options?: EcoRouterOptions;
21
+ /** Children (should contain {@link PageContent}) */
22
+ children: ReactNode;
23
+ }
24
+ /**
25
+ * Clears the layout cache. Called during HMR to ensure fresh layouts are used.
26
+ */
27
+ export declare function clearLayoutCache(): void;
28
+ /**
29
+ * Renders the current page with its layout.
30
+ *
31
+ * Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
32
+ * shared layouts remain mounted across navigations.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <EcoRouter page={Page} pageProps={props}>
37
+ * <PageContent />
38
+ * </EcoRouter>
39
+ * ```
40
+ */
41
+ export declare const PageContent: FC;
42
+ /**
43
+ * Root router providing SPA navigation with View Transitions.
44
+ *
45
+ * Coordinates navigation flow:
46
+ * 1. Intercepts link clicks and popstate events
47
+ * 2. Loads page module and updates document head
48
+ * 3. Triggers View Transition (if supported)
49
+ * 4. Updates React state inside transition callback
50
+ * 5. Resolves deferred promise after render
51
+ * 6. Browser captures new DOM snapshot
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * <EcoRouter
56
+ * page={CurrentPage}
57
+ * pageProps={pageProps}
58
+ * options={{ persistLayouts: true }}
59
+ * >
60
+ * <PageContent />
61
+ * </EcoRouter>
62
+ * ```
63
+ *
64
+ * @example Shared element transitions
65
+ * ```tsx
66
+ * // List page
67
+ * <img data-view-transition={`hero-${id}`} src={src} />
68
+ *
69
+ * // Detail page
70
+ * <img data-view-transition={`hero-${id}`} src={src} />
71
+ * ```
72
+ */
73
+ export declare const EcoRouter: FC<EcoRouterProps>;
package/src/router.js ADDED
@@ -0,0 +1,225 @@
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ useCallback,
5
+ useMemo,
6
+ useRef,
7
+ createContext,
8
+ useContext,
9
+ startTransition,
10
+ createElement
11
+ } from "react";
12
+ import { DEFAULT_OPTIONS } from "./types.js";
13
+ import { RouterContext } from "./context.js";
14
+ import { getInterceptDecision, loadPageModule, shouldInterceptClick } from "./navigation.js";
15
+ import { morphHead } from "./head-morpher.js";
16
+ import { applyViewTransitionNames } from "./view-transition-utils.js";
17
+ import { manageScroll } from "./manage-scroll.js";
18
+ import { saveScrollPositions, restoreScrollPositions } from "./scroll-persist.js";
19
+ const PageContext = createContext(null);
20
+ const PersistLayoutsContext = createContext(false);
21
+ function getLayoutFromPage(Page) {
22
+ const config = Page.config;
23
+ return config?.layout;
24
+ }
25
+ function getLayoutCache() {
26
+ if (typeof window === "undefined") {
27
+ return /* @__PURE__ */ new Map();
28
+ }
29
+ const win = window;
30
+ if (!win.__ecoLayoutCache) {
31
+ win.__ecoLayoutCache = /* @__PURE__ */ new Map();
32
+ }
33
+ return win.__ecoLayoutCache;
34
+ }
35
+ function normalizeLayoutKey(value) {
36
+ const trimmed = value.trim();
37
+ if (!trimmed) return "layout";
38
+ try {
39
+ const asUrl = new URL(trimmed);
40
+ return asUrl.pathname.replace(/\/$/, "") || "layout";
41
+ } catch {
42
+ return trimmed.split("#")[0]?.split("?")[0]?.replace(/\/$/, "") || "layout";
43
+ }
44
+ }
45
+ function clearLayoutCache() {
46
+ getLayoutCache().clear();
47
+ }
48
+ const PageContent = () => {
49
+ const pageContext = useContext(PageContext);
50
+ const persistLayouts = useContext(PersistLayoutsContext);
51
+ if (!pageContext) {
52
+ if (import.meta.env.NODE_ENV !== "production") {
53
+ console.warn("[EcoRouter] PageContent used outside of EcoRouter");
54
+ }
55
+ return null;
56
+ }
57
+ const { Component: Page, props } = pageContext;
58
+ const Layout = getLayoutFromPage(Page);
59
+ const pageElement = createElement(Page, props);
60
+ if (!Layout) {
61
+ return pageElement;
62
+ }
63
+ if (persistLayouts) {
64
+ const layoutCache = getLayoutCache();
65
+ const layoutConfig = Layout.config;
66
+ const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || "layout";
67
+ const layoutKey = normalizeLayoutKey(layoutKeyRaw);
68
+ if (!layoutCache.has(layoutKey)) {
69
+ layoutCache.set(layoutKey, Layout);
70
+ }
71
+ const CachedLayout = layoutCache.get(layoutKey);
72
+ return createElement(CachedLayout, { key: layoutKey }, pageElement);
73
+ }
74
+ return createElement(Layout, null, pageElement);
75
+ };
76
+ function createDeferred() {
77
+ let resolve;
78
+ const promise = new Promise((res) => {
79
+ resolve = res;
80
+ });
81
+ return { promise, resolve };
82
+ }
83
+ function useHmrReload(navigate) {
84
+ useEffect(() => {
85
+ if (typeof window === "undefined") return;
86
+ if (import.meta.env?.MODE === "production" || import.meta.env?.PROD) return;
87
+ const windowWithHmr = window;
88
+ windowWithHmr.__ecopages_reload_current_page__ = async (options) => {
89
+ if (options?.clearCache) {
90
+ clearLayoutCache();
91
+ }
92
+ const currentUrl = window.location.pathname + window.location.search;
93
+ await navigate(currentUrl);
94
+ };
95
+ return () => {
96
+ windowWithHmr.__ecopages_reload_current_page__ = void 0;
97
+ };
98
+ }, [navigate]);
99
+ }
100
+ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
101
+ const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
102
+ const [currentPage, setCurrentPage] = useState({ Component: page, props: pageProps });
103
+ const [isNavigating, setIsNavigating] = useState(false);
104
+ const [pendingPage, setPendingPage] = useState(null);
105
+ const renderDfd = useRef(null);
106
+ const pendingScrollRestoreRef = useRef(null);
107
+ const previousUrlRef = useRef(typeof window !== "undefined" ? window.location.href : "");
108
+ useEffect(() => {
109
+ setCurrentPage({ Component: page, props: pageProps });
110
+ }, [page, pageProps]);
111
+ useEffect(() => {
112
+ applyViewTransitionNames();
113
+ }, [currentPage]);
114
+ useEffect(() => {
115
+ if (pendingPage && currentPage.Component === pendingPage.Component && renderDfd.current) {
116
+ renderDfd.current.resolve();
117
+ renderDfd.current = null;
118
+ setPendingPage(null);
119
+ }
120
+ }, [currentPage, pendingPage]);
121
+ useEffect(() => {
122
+ if (typeof window === "undefined") return;
123
+ const url = new URL(window.location.href);
124
+ const previousUrl = new URL(previousUrlRef.current);
125
+ if (url.href !== previousUrl.href) {
126
+ manageScroll(url, previousUrl, {
127
+ scrollBehavior: options.scrollBehavior,
128
+ smoothScroll: options.smoothScroll
129
+ });
130
+ previousUrlRef.current = url.href;
131
+ }
132
+ if (pendingScrollRestoreRef.current) {
133
+ const { url: targetUrl, isPopState } = pendingScrollRestoreRef.current;
134
+ restoreScrollPositions(targetUrl, isPopState);
135
+ pendingScrollRestoreRef.current = null;
136
+ }
137
+ }, [currentPage, options.scrollBehavior, options.smoothScroll]);
138
+ const navigate = useCallback(
139
+ async (url, isPopState = false) => {
140
+ setIsNavigating(true);
141
+ const result = await loadPageModule(url);
142
+ if (result) {
143
+ const { Component, props, doc, finalPath } = result;
144
+ const nextPage = { Component, props };
145
+ const cleanupHead = await morphHead(doc);
146
+ applyViewTransitionNames();
147
+ if (finalPath !== url) {
148
+ window.history.replaceState(null, "", finalPath);
149
+ }
150
+ saveScrollPositions();
151
+ pendingScrollRestoreRef.current = { url, isPopState };
152
+ if (options.viewTransitions && document.startViewTransition) {
153
+ renderDfd.current = createDeferred();
154
+ setPendingPage(nextPage);
155
+ document.startViewTransition(async () => {
156
+ startTransition(() => {
157
+ setCurrentPage(nextPage);
158
+ });
159
+ await renderDfd.current?.promise;
160
+ cleanupHead();
161
+ applyViewTransitionNames();
162
+ });
163
+ } else {
164
+ setCurrentPage(nextPage);
165
+ cleanupHead();
166
+ applyViewTransitionNames();
167
+ }
168
+ } else {
169
+ if (options.debug) {
170
+ console.error("[EcoRouter] Falling back to full page navigation:", url);
171
+ }
172
+ window.location.href = url;
173
+ }
174
+ setIsNavigating(false);
175
+ },
176
+ [options.viewTransitions, options.debug]
177
+ );
178
+ useEffect(() => {
179
+ const handleClick = (event) => {
180
+ const link = event.target.closest(options.linkSelector);
181
+ if (!link) return;
182
+ if (!shouldInterceptClick(event, link, options)) {
183
+ if (options.debug) {
184
+ const decision = getInterceptDecision(event, link, options);
185
+ if (!decision.shouldIntercept) {
186
+ console.debug("[EcoRouter] Not intercepting link click:", decision.reason, link.href);
187
+ }
188
+ }
189
+ return;
190
+ }
191
+ event.preventDefault();
192
+ const href = link.getAttribute("href");
193
+ const url = new URL(href, window.location.origin);
194
+ if (options.debug) {
195
+ console.debug("[EcoRouter] Intercepting navigation:", url.pathname + url.search);
196
+ }
197
+ window.history.pushState(null, "", url.href);
198
+ navigate(url.pathname + url.search);
199
+ };
200
+ const handlePopState = () => {
201
+ navigate(window.location.pathname + window.location.search, true);
202
+ };
203
+ document.addEventListener("click", handleClick);
204
+ window.addEventListener("popstate", handlePopState);
205
+ return () => {
206
+ document.removeEventListener("click", handleClick);
207
+ window.removeEventListener("popstate", handlePopState);
208
+ };
209
+ }, [navigate, options]);
210
+ useHmrReload(navigate);
211
+ return createElement(
212
+ RouterContext.Provider,
213
+ { value: { navigate, isNavigating } },
214
+ createElement(
215
+ PersistLayoutsContext.Provider,
216
+ { value: options.persistLayouts },
217
+ createElement(PageContext.Provider, { value: currentPage }, children)
218
+ )
219
+ );
220
+ };
221
+ export {
222
+ EcoRouter,
223
+ PageContent,
224
+ clearLayoutCache
225
+ };