@ecopages/react-router 0.2.0-alpha.5 → 0.2.0-alpha.8

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.js CHANGED
@@ -11,11 +11,21 @@ import {
11
11
  } from "react";
12
12
  import { DEFAULT_OPTIONS } from "./types.js";
13
13
  import { RouterContext } from "./context.js";
14
- import { getInterceptDecision, loadPageModule, shouldInterceptClick } from "./navigation.js";
14
+ import {
15
+ fetchPageDocument,
16
+ getInterceptDecision,
17
+ loadPageModuleFromDocument,
18
+ shouldInterceptClick
19
+ } from "./navigation.js";
15
20
  import { morphHead } from "./head-morpher.js";
16
21
  import { applyViewTransitionNames } from "./view-transition-utils.js";
17
22
  import { manageScroll } from "./manage-scroll.js";
18
23
  import { saveScrollPositions, restoreScrollPositions } from "./scroll-persist.js";
24
+ import { getEcoNavigationRuntime } from "@ecopages/core/router/navigation-coordinator";
25
+ import {
26
+ getAnchorFromNavigationEvent,
27
+ recoverPendingNavigationHref
28
+ } from "@ecopages/core/router/link-intent";
19
29
  const PageContext = createContext(null);
20
30
  const PersistLayoutsContext = createContext(false);
21
31
  function getLayoutFromPage(Page) {
@@ -57,6 +67,7 @@ const PageContent = () => {
57
67
  const { Component: Page, props } = pageContext;
58
68
  const Layout = getLayoutFromPage(Page);
59
69
  const pageElement = createElement(Page, props);
70
+ const layoutProps = props?.locals ? { locals: props.locals } : null;
60
71
  if (!Layout) {
61
72
  return pageElement;
62
73
  }
@@ -69,9 +80,9 @@ const PageContent = () => {
69
80
  layoutCache.set(layoutKey, Layout);
70
81
  }
71
82
  const CachedLayout = layoutCache.get(layoutKey);
72
- return createElement(CachedLayout, { key: layoutKey }, pageElement);
83
+ return createElement(CachedLayout, { key: layoutKey, ...layoutProps ?? {} }, pageElement);
73
84
  }
74
- return createElement(Layout, null, pageElement);
85
+ return createElement(Layout, layoutProps, pageElement);
75
86
  };
76
87
  function createDeferred() {
77
88
  let resolve;
@@ -80,31 +91,65 @@ function createDeferred() {
80
91
  });
81
92
  return { promise, resolve };
82
93
  }
83
- function useHmrReload(navigate) {
94
+ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef) {
84
95
  useEffect(() => {
85
96
  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();
97
+ const navigationRuntime = getEcoNavigationRuntime(window);
98
+ let unregisterRuntime = null;
99
+ const unregister = navigationRuntime.register({
100
+ owner: "react-router",
101
+ navigate: async (request) => {
102
+ await navigate(request.href, {
103
+ isPopState: request.direction === "back",
104
+ pushHistory: request.direction === "forward",
105
+ skipViewTransition: request.source === "browser-router"
106
+ });
107
+ return true;
108
+ },
109
+ reloadCurrentPage: async (request) => {
110
+ if (activeNavigationRef.current || isNavigatingRef.current) {
111
+ return;
112
+ }
113
+ if (request?.clearCache) {
114
+ clearLayoutCache();
115
+ }
116
+ const currentUrl = window.location.pathname + window.location.search;
117
+ await navigate(currentUrl);
118
+ },
119
+ cleanupBeforeHandoff: async () => {
120
+ runtimeActiveRef.current = false;
121
+ unregisterRuntime?.();
122
+ unregisterRuntime = null;
123
+ window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
91
124
  }
92
- const currentUrl = window.location.pathname + window.location.search;
93
- await navigate(currentUrl);
94
- };
125
+ });
126
+ unregisterRuntime = unregister;
127
+ navigationRuntime.claimOwnership("react-router");
128
+ runtimeActiveRef.current = true;
95
129
  return () => {
96
- windowWithHmr.__ecopages_reload_current_page__ = void 0;
130
+ runtimeActiveRef.current = false;
131
+ navigationRuntime.releaseOwnership("react-router");
132
+ unregisterRuntime?.();
133
+ unregisterRuntime = null;
97
134
  };
98
- }, [navigate]);
135
+ }, [activeNavigationRef, isNavigatingRef, navigate, runtimeActiveRef]);
99
136
  }
100
137
  const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
101
138
  const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
102
139
  const [currentPage, setCurrentPage] = useState({ Component: page, props: pageProps });
103
140
  const [isNavigating, setIsNavigating] = useState(false);
104
- const [pendingPage, setPendingPage] = useState(null);
105
- const renderDfd = useRef(null);
141
+ const pendingRenderRef = useRef(null);
142
+ const activeNavigationRef = useRef(null);
143
+ const isNavigatingRef = useRef(false);
144
+ const runtimeActiveRef = useRef(true);
145
+ const pendingPointerNavigationRef = useRef(null);
146
+ const pendingHoverNavigationRef = useRef(null);
147
+ const queuedNavigationHrefRef = useRef(null);
106
148
  const pendingScrollRestoreRef = useRef(null);
107
149
  const previousUrlRef = useRef(typeof window !== "undefined" ? window.location.href : "");
150
+ useEffect(() => {
151
+ isNavigatingRef.current = isNavigating;
152
+ }, [isNavigating]);
108
153
  useEffect(() => {
109
154
  setCurrentPage({ Component: page, props: pageProps });
110
155
  }, [page, pageProps]);
@@ -112,12 +157,20 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
112
157
  applyViewTransitionNames();
113
158
  }, [currentPage]);
114
159
  useEffect(() => {
115
- if (pendingPage && currentPage.Component === pendingPage.Component && renderDfd.current) {
116
- renderDfd.current.resolve();
117
- renderDfd.current = null;
118
- setPendingPage(null);
160
+ const pendingRender = pendingRenderRef.current;
161
+ if (pendingRender && currentPage.Component === pendingRender.page.Component && currentPage.props === pendingRender.page.props) {
162
+ pendingRender.resolve();
163
+ pendingRenderRef.current = null;
119
164
  }
120
- }, [currentPage, pendingPage]);
165
+ }, [currentPage]);
166
+ useEffect(() => {
167
+ return () => {
168
+ activeNavigationRef.current?.cancel();
169
+ pendingRenderRef.current?.resolve();
170
+ pendingRenderRef.current = null;
171
+ queuedNavigationHrefRef.current = null;
172
+ };
173
+ }, []);
121
174
  useEffect(() => {
122
175
  if (typeof window === "undefined") return;
123
176
  const url = new URL(window.location.href);
@@ -136,49 +189,215 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
136
189
  }
137
190
  }, [currentPage, options.scrollBehavior, options.smoothScroll]);
138
191
  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);
192
+ async (url, navigationOptions = {}) => {
193
+ const { isPopState = false, pushHistory = false, skipViewTransition = false } = navigationOptions;
194
+ const navigationRuntime = getEcoNavigationRuntime(window);
195
+ const navigation = navigationRuntime.beginNavigationTransaction();
196
+ activeNavigationRef.current = navigation;
197
+ const navigationId = navigation.id;
198
+ const isStale = () => !navigation.isCurrent();
199
+ const commitPageData = (moduleUrl, props) => {
200
+ window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
201
+ window.__ECO_PAGES__.page = {
202
+ module: moduleUrl,
203
+ props
204
+ };
205
+ };
206
+ let navigationCommitPromise = null;
207
+ try {
208
+ setIsNavigating(true);
209
+ const fetchedPage = await fetchPageDocument(url, { signal: navigation.signal });
210
+ if (isStale()) return;
211
+ if (!fetchedPage) {
212
+ window.location.href = url;
213
+ return;
149
214
  }
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);
215
+ const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
216
+ if (isStale()) return;
217
+ if (result) {
218
+ const { Component, props, doc, finalPath, moduleUrl } = result;
219
+ const nextPage = { Component, props };
220
+ const cleanupHead = await morphHead(doc);
221
+ if (isStale()) {
222
+ cleanupHead();
223
+ return;
224
+ }
225
+ applyViewTransitionNames();
226
+ if (pushHistory) {
227
+ window.history.pushState(null, "", finalPath);
228
+ } else if (finalPath !== url) {
229
+ window.history.replaceState(null, "", finalPath);
230
+ }
231
+ saveScrollPositions();
232
+ pendingScrollRestoreRef.current = { url, isPopState };
233
+ if (!skipViewTransition && options.viewTransitions && document.startViewTransition) {
234
+ pendingRenderRef.current?.resolve();
235
+ const renderDfd = createDeferred();
236
+ pendingRenderRef.current = {
237
+ navigationId,
238
+ page: nextPage,
239
+ resolve: renderDfd.resolve
240
+ };
241
+ navigationCommitPromise = new Promise((resolve) => {
242
+ document.startViewTransition(async () => {
243
+ try {
244
+ if (isStale()) {
245
+ if (pendingRenderRef.current?.navigationId === navigationId) {
246
+ pendingRenderRef.current.resolve();
247
+ pendingRenderRef.current = null;
248
+ }
249
+ cleanupHead();
250
+ return;
251
+ }
252
+ startTransition(() => {
253
+ commitPageData(moduleUrl, props);
254
+ setCurrentPage(nextPage);
255
+ });
256
+ await renderDfd.promise;
257
+ if (isStale()) {
258
+ return;
259
+ }
260
+ cleanupHead();
261
+ applyViewTransitionNames();
262
+ } finally {
263
+ resolve();
264
+ }
265
+ });
158
266
  });
159
- await renderDfd.current?.promise;
267
+ await navigationCommitPromise;
268
+ } else {
269
+ commitPageData(moduleUrl, props);
270
+ setCurrentPage(nextPage);
160
271
  cleanupHead();
161
272
  applyViewTransitionNames();
162
- });
273
+ }
163
274
  } else {
164
- setCurrentPage(nextPage);
165
- cleanupHead();
166
- applyViewTransitionNames();
275
+ if (isStale()) return;
276
+ const handled = await navigationRuntime.requestHandoff({
277
+ href: url,
278
+ finalHref: fetchedPage.finalPath,
279
+ direction: isPopState ? "back" : pushHistory ? "forward" : "replace",
280
+ source: "react-router",
281
+ targetOwner: "browser-router",
282
+ document: fetchedPage.doc,
283
+ html: fetchedPage.html,
284
+ isStaleSourceNavigation: isStale
285
+ });
286
+ if (!handled) {
287
+ window.location.assign(fetchedPage.finalPath);
288
+ }
167
289
  }
168
- } else {
169
- if (options.debug) {
170
- console.error("[EcoRouter] Falling back to full page navigation:", url);
290
+ if (!isStale()) {
291
+ setIsNavigating(false);
292
+ }
293
+ } finally {
294
+ const shouldReplayQueuedNavigation = activeNavigationRef.current?.id === navigationId;
295
+ const queuedNavigationHref = shouldReplayQueuedNavigation ? queuedNavigationHrefRef.current : null;
296
+ navigation.complete();
297
+ if (activeNavigationRef.current?.id === navigationId) {
298
+ activeNavigationRef.current = null;
299
+ }
300
+ if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
301
+ queuedNavigationHrefRef.current = null;
302
+ if (runtimeActiveRef.current) {
303
+ void navigate(queuedNavigationHref, { pushHistory: true });
304
+ } else {
305
+ void navigationRuntime.requestNavigation({
306
+ href: queuedNavigationHref,
307
+ direction: "forward",
308
+ source: "react-router"
309
+ }).then((handled) => {
310
+ if (!handled) {
311
+ window.location.assign(queuedNavigationHref);
312
+ }
313
+ });
314
+ }
171
315
  }
172
- window.location.href = url;
173
316
  }
174
- setIsNavigating(false);
175
317
  },
176
- [options.viewTransitions, options.debug]
318
+ [options.viewTransitions]
177
319
  );
178
320
  useEffect(() => {
321
+ const getLinkFromEvent = (event) => getAnchorFromNavigationEvent(event, options.linkSelector);
322
+ const getRecoveredPointerHref = () => {
323
+ const href = recoverPendingNavigationHref(
324
+ pendingPointerNavigationRef.current,
325
+ !!activeNavigationRef.current || isNavigatingRef.current,
326
+ performance.now()
327
+ );
328
+ if (!href) {
329
+ pendingPointerNavigationRef.current = null;
330
+ }
331
+ return href;
332
+ };
333
+ const getRecoveredHoverHref = () => {
334
+ const href = recoverPendingNavigationHref(
335
+ pendingHoverNavigationRef.current,
336
+ !!activeNavigationRef.current || isNavigatingRef.current,
337
+ performance.now()
338
+ );
339
+ if (!href) {
340
+ pendingHoverNavigationRef.current = null;
341
+ }
342
+ return href;
343
+ };
344
+ const handleHoverIntent = (event) => {
345
+ if (!runtimeActiveRef.current) {
346
+ return;
347
+ }
348
+ const link = getLinkFromEvent(event);
349
+ if (!link) {
350
+ return;
351
+ }
352
+ const decision = getInterceptDecision(event, link, options);
353
+ if (!decision.shouldIntercept) {
354
+ return;
355
+ }
356
+ pendingHoverNavigationRef.current = {
357
+ href: link.getAttribute("href"),
358
+ timestamp: performance.now()
359
+ };
360
+ queuedNavigationHrefRef.current = link.getAttribute("href");
361
+ };
362
+ const handlePointerDown = (event) => {
363
+ if (!runtimeActiveRef.current) {
364
+ pendingPointerNavigationRef.current = null;
365
+ return;
366
+ }
367
+ const link = getLinkFromEvent(event);
368
+ if (!link) {
369
+ pendingPointerNavigationRef.current = null;
370
+ return;
371
+ }
372
+ const decision = getInterceptDecision(event, link, options);
373
+ pendingPointerNavigationRef.current = decision.shouldIntercept ? {
374
+ href: link.getAttribute("href"),
375
+ timestamp: performance.now()
376
+ } : null;
377
+ if (decision.shouldIntercept && (activeNavigationRef.current || isNavigatingRef.current)) {
378
+ queuedNavigationHrefRef.current = link.getAttribute("href");
379
+ }
380
+ };
179
381
  const handleClick = (event) => {
180
- const link = event.target.closest(options.linkSelector);
181
- if (!link) return;
382
+ if (!runtimeActiveRef.current) {
383
+ pendingPointerNavigationRef.current = null;
384
+ pendingHoverNavigationRef.current = null;
385
+ return;
386
+ }
387
+ const link = getLinkFromEvent(event);
388
+ if (!link) {
389
+ const recoveredHref = getRecoveredPointerHref() ?? getRecoveredHoverHref();
390
+ pendingPointerNavigationRef.current = null;
391
+ pendingHoverNavigationRef.current = null;
392
+ if (!recoveredHref) {
393
+ return;
394
+ }
395
+ event.preventDefault();
396
+ queuedNavigationHrefRef.current = null;
397
+ const recoveredUrl = new URL(recoveredHref, window.location.origin);
398
+ navigate(recoveredUrl.pathname + recoveredUrl.search, { pushHistory: true });
399
+ return;
400
+ }
182
401
  if (!shouldInterceptClick(event, link, options)) {
183
402
  if (options.debug) {
184
403
  const decision = getInterceptDecision(event, link, options);
@@ -186,28 +405,45 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
186
405
  console.debug("[EcoRouter] Not intercepting link click:", decision.reason, link.href);
187
406
  }
188
407
  }
408
+ pendingPointerNavigationRef.current = null;
409
+ pendingHoverNavigationRef.current = null;
189
410
  return;
190
411
  }
412
+ pendingPointerNavigationRef.current = null;
413
+ pendingHoverNavigationRef.current = null;
191
414
  event.preventDefault();
415
+ queuedNavigationHrefRef.current = null;
192
416
  const href = link.getAttribute("href");
193
417
  const url = new URL(href, window.location.origin);
194
418
  if (options.debug) {
195
419
  console.debug("[EcoRouter] Intercepting navigation:", url.pathname + url.search);
196
420
  }
197
- window.history.pushState(null, "", url.href);
198
- navigate(url.pathname + url.search);
421
+ navigate(url.pathname + url.search, { pushHistory: true });
199
422
  };
200
423
  const handlePopState = () => {
201
- navigate(window.location.pathname + window.location.search, true);
424
+ if (!runtimeActiveRef.current) {
425
+ return;
426
+ }
427
+ navigate(window.location.pathname + window.location.search, { isPopState: true });
202
428
  };
203
- document.addEventListener("click", handleClick);
429
+ document.addEventListener("mouseover", handleHoverIntent, true);
430
+ document.addEventListener("pointerover", handleHoverIntent, true);
431
+ document.addEventListener("mousemove", handleHoverIntent, true);
432
+ document.addEventListener("pointermove", handleHoverIntent, true);
433
+ document.addEventListener("pointerdown", handlePointerDown, true);
434
+ document.addEventListener("click", handleClick, true);
204
435
  window.addEventListener("popstate", handlePopState);
205
436
  return () => {
206
- document.removeEventListener("click", handleClick);
437
+ document.removeEventListener("mouseover", handleHoverIntent, true);
438
+ document.removeEventListener("pointerover", handleHoverIntent, true);
439
+ document.removeEventListener("mousemove", handleHoverIntent, true);
440
+ document.removeEventListener("pointermove", handleHoverIntent, true);
441
+ document.removeEventListener("pointerdown", handlePointerDown, true);
442
+ document.removeEventListener("click", handleClick, true);
207
443
  window.removeEventListener("popstate", handlePopState);
208
444
  };
209
- }, [navigate, options]);
210
- useHmrReload(navigate);
445
+ }, [navigate, options, runtimeActiveRef]);
446
+ useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
211
447
  return createElement(
212
448
  RouterContext.Provider,
213
449
  { value: { navigate, isNavigating } },