@ecopages/react-router 0.2.0-alpha.9 → 0.2.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.
- package/CHANGELOG.md +3 -5
- package/package.json +3 -3
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +67 -7
- package/src/router.js +15 -1
- package/browser.ts +0 -17
- package/src/adapter.ts +0 -48
- package/src/context.ts +0 -25
- package/src/head-morpher.ts +0 -214
- package/src/index.ts +0 -21
- package/src/manage-scroll.ts +0 -47
- package/src/navigation.ts +0 -297
- package/src/props-script.ts +0 -19
- package/src/router.ts +0 -670
- package/src/scroll-persist.ts +0 -96
- package/src/types.ts +0 -64
- package/src/view-transition-manager.ts +0 -30
- package/src/view-transition-utils.ts +0 -95
package/CHANGELOG.md
CHANGED
|
@@ -4,14 +4,12 @@ All notable changes to `@ecopages/react-router` are documented here.
|
|
|
4
4
|
|
|
5
5
|
> **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.2.1] — 2026-04-16
|
|
8
8
|
|
|
9
9
|
### Bug Fixes
|
|
10
10
|
|
|
11
|
-
- Fixed React-to-
|
|
12
|
-
-
|
|
13
|
-
- Standardized React route payload reads on `window.__ECO_PAGES__.page` and explicit document owner markers so mixed-router page ownership stays stable.
|
|
14
|
-
- Restored current-page HMR refreshes with persist layouts enabled by targeting the active React-router owner.
|
|
11
|
+
- Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
|
|
12
|
+
- Standardized route payload reads, document-owner markers, rerun scripts, and current-page HMR refreshes for persisted React layouts.
|
|
15
13
|
|
|
16
14
|
### Refactoring
|
|
17
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Client-side SPA router for EcoPages React applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"directory": "packages/react-router"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@ecopages/core": "0.2.
|
|
36
|
-
"@ecopages/react": "0.2.
|
|
35
|
+
"@ecopages/core": "0.2.1",
|
|
36
|
+
"@ecopages/react": "0.2.1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"react": "^19",
|
package/src/head-morpher.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* Intelligently syncs head elements between pages using key-based diffing.
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
+
export type HeadMorphResult = {
|
|
7
|
+
cleanup: () => void;
|
|
8
|
+
flushRerunScripts: () => void;
|
|
9
|
+
};
|
|
6
10
|
/**
|
|
7
11
|
* Morphs the current document head to match the new document's head.
|
|
8
12
|
* Now splits the process into adding new elements and returning a cleanup function
|
|
@@ -10,6 +14,6 @@
|
|
|
10
14
|
* don't disappear before the "old" snapshot is taken.
|
|
11
15
|
*
|
|
12
16
|
* @param newDocument - The parsed document from the navigation target
|
|
13
|
-
* @returns Promise that resolves to
|
|
17
|
+
* @returns Promise that resolves to cleanup and rerun hooks when new stylesheets have loaded
|
|
14
18
|
*/
|
|
15
|
-
export declare function morphHead(newDocument: Document): Promise<
|
|
19
|
+
export declare function morphHead(newDocument: Document): Promise<HeadMorphResult>;
|
package/src/head-morpher.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
|
|
2
|
+
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
3
|
+
let rerunNonce = 0;
|
|
2
4
|
function isNonExecutableHeadScript(el) {
|
|
3
5
|
if (el.tagName !== "SCRIPT") {
|
|
4
6
|
return false;
|
|
@@ -31,6 +33,13 @@ function shouldPersistExecutableInlineHeadScript(el) {
|
|
|
31
33
|
}
|
|
32
34
|
return !isNonExecutableHeadScript(el);
|
|
33
35
|
}
|
|
36
|
+
function isRerunScript(el) {
|
|
37
|
+
return el.tagName === "SCRIPT" && el.hasAttribute("data-eco-rerun");
|
|
38
|
+
}
|
|
39
|
+
function isHydrationScript(el) {
|
|
40
|
+
const src = el.getAttribute("src");
|
|
41
|
+
return !!src && src.includes("hydration.js") && src.includes("ecopages-react");
|
|
42
|
+
}
|
|
34
43
|
function getHeadElementKey(el) {
|
|
35
44
|
const tag = el.tagName.toLowerCase();
|
|
36
45
|
switch (tag) {
|
|
@@ -52,7 +61,7 @@ function getHeadElementKey(el) {
|
|
|
52
61
|
if (el.getAttribute("type") === "importmap") return "importmap";
|
|
53
62
|
const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
|
|
54
63
|
if (scriptId) return `script-id:${scriptId}`;
|
|
55
|
-
const src = el.src;
|
|
64
|
+
const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
|
|
56
65
|
return src ? `script:${src}` : null;
|
|
57
66
|
}
|
|
58
67
|
case "style": {
|
|
@@ -70,6 +79,12 @@ async function morphHead(newDocument) {
|
|
|
70
79
|
const newElements = /* @__PURE__ */ new Map();
|
|
71
80
|
const stylesheetPromises = [];
|
|
72
81
|
const elementsToRemove = [];
|
|
82
|
+
const pendingRerunScripts = Array.from(newHead.querySelectorAll("script[data-eco-rerun]")).filter((script) => !isHydrationScript(script)).map((script) => ({
|
|
83
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
84
|
+
textContent: script.textContent ?? "",
|
|
85
|
+
scriptId: script.getAttribute("data-eco-script-id"),
|
|
86
|
+
src: script.getAttribute("src")
|
|
87
|
+
}));
|
|
73
88
|
for (const el of Array.from(currentHead.children)) {
|
|
74
89
|
const key = getHeadElementKey(el);
|
|
75
90
|
if (key) currentElements.set(key, el);
|
|
@@ -80,9 +95,11 @@ async function morphHead(newDocument) {
|
|
|
80
95
|
}
|
|
81
96
|
for (const [key, newEl] of newElements) {
|
|
82
97
|
const currentEl = currentElements.get(key);
|
|
98
|
+
if (isRerunScript(newEl)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
83
101
|
if (!currentEl) {
|
|
84
|
-
|
|
85
|
-
if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
|
|
102
|
+
if (newEl.tagName === "SCRIPT" && isHydrationScript(newEl)) {
|
|
86
103
|
continue;
|
|
87
104
|
}
|
|
88
105
|
const cloned = newEl.cloneNode(true);
|
|
@@ -104,7 +121,7 @@ async function morphHead(newDocument) {
|
|
|
104
121
|
}
|
|
105
122
|
for (const newEl of Array.from(newHead.children)) {
|
|
106
123
|
const key = getHeadElementKey(newEl);
|
|
107
|
-
if (!key) {
|
|
124
|
+
if (!key && !isRerunScript(newEl)) {
|
|
108
125
|
currentHead.appendChild(newEl.cloneNode(true));
|
|
109
126
|
}
|
|
110
127
|
}
|
|
@@ -119,12 +136,55 @@ async function morphHead(newDocument) {
|
|
|
119
136
|
}
|
|
120
137
|
}
|
|
121
138
|
}
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
el
|
|
139
|
+
return {
|
|
140
|
+
cleanup: () => {
|
|
141
|
+
for (const el of elementsToRemove) {
|
|
142
|
+
el.remove();
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
flushRerunScripts: () => {
|
|
146
|
+
for (const script of pendingRerunScripts) {
|
|
147
|
+
const replacement = document.createElement("script");
|
|
148
|
+
const shouldBustModuleSrc = isExternalModuleRerunScript(script);
|
|
149
|
+
for (const [name, value] of script.attributes) {
|
|
150
|
+
if (name === "src" && shouldBustModuleSrc) {
|
|
151
|
+
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
152
|
+
replacement.setAttribute("src", createRerunScriptUrl(value));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
replacement.setAttribute(name, value);
|
|
156
|
+
}
|
|
157
|
+
replacement.textContent = script.textContent;
|
|
158
|
+
const existingScript = findExistingRerunScript(script);
|
|
159
|
+
if (existingScript) {
|
|
160
|
+
existingScript.replaceWith(replacement);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
document.head.appendChild(replacement);
|
|
164
|
+
}
|
|
125
165
|
}
|
|
126
166
|
};
|
|
127
167
|
}
|
|
168
|
+
function findExistingRerunScript(script) {
|
|
169
|
+
const scripts = Array.from(document.head.querySelectorAll("script"));
|
|
170
|
+
if (script.scriptId) {
|
|
171
|
+
return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
|
|
172
|
+
}
|
|
173
|
+
return scripts.find(
|
|
174
|
+
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
175
|
+
) ?? null;
|
|
176
|
+
}
|
|
177
|
+
function isExternalModuleRerunScript(script) {
|
|
178
|
+
if (!script.src) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
182
|
+
}
|
|
183
|
+
function createRerunScriptUrl(src) {
|
|
184
|
+
const url = new URL(src, document.baseURI);
|
|
185
|
+
url.searchParams.set("__eco_rerun", String(++rerunNonce));
|
|
186
|
+
return url.toString();
|
|
187
|
+
}
|
|
128
188
|
export {
|
|
129
189
|
morphHead
|
|
130
190
|
};
|
package/src/router.js
CHANGED
|
@@ -217,7 +217,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
217
217
|
if (result) {
|
|
218
218
|
const { Component, props, doc, finalPath, moduleUrl } = result;
|
|
219
219
|
const nextPage = { Component, props };
|
|
220
|
-
const cleanupHead = await morphHead(doc);
|
|
220
|
+
const { cleanup: cleanupHead, flushRerunScripts } = await morphHead(doc);
|
|
221
221
|
if (isStale()) {
|
|
222
222
|
cleanupHead();
|
|
223
223
|
return;
|
|
@@ -257,6 +257,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
257
257
|
if (isStale()) {
|
|
258
258
|
return;
|
|
259
259
|
}
|
|
260
|
+
flushRerunScripts();
|
|
260
261
|
cleanupHead();
|
|
261
262
|
applyViewTransitionNames();
|
|
262
263
|
} finally {
|
|
@@ -266,8 +267,21 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
266
267
|
});
|
|
267
268
|
await navigationCommitPromise;
|
|
268
269
|
} else {
|
|
270
|
+
pendingRenderRef.current?.resolve();
|
|
271
|
+
const renderDfd = createDeferred();
|
|
272
|
+
pendingRenderRef.current = {
|
|
273
|
+
navigationId,
|
|
274
|
+
page: nextPage,
|
|
275
|
+
resolve: renderDfd.resolve
|
|
276
|
+
};
|
|
269
277
|
commitPageData(moduleUrl, props);
|
|
270
278
|
setCurrentPage(nextPage);
|
|
279
|
+
await renderDfd.promise;
|
|
280
|
+
if (isStale()) {
|
|
281
|
+
cleanupHead();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
flushRerunScripts();
|
|
271
285
|
cleanupHead();
|
|
272
286
|
applyViewTransitionNames();
|
|
273
287
|
}
|
package/browser.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser entry point for @ecopages/react-router.
|
|
3
|
-
* This file exports only the client-side components needed for hydration.
|
|
4
|
-
* @module
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export { EcoRouter, PageContent } from './src/router.ts';
|
|
8
|
-
export type { EcoRouterProps } from './src/router.ts';
|
|
9
|
-
|
|
10
|
-
export { useRouter } from './src/context.ts';
|
|
11
|
-
export type { RouterContextValue } from './src/context.ts';
|
|
12
|
-
export { EcoPropsScript } from './src/props-script.ts';
|
|
13
|
-
export type { EcoPropsScriptProps } from './src/props-script.ts';
|
|
14
|
-
|
|
15
|
-
export { morphHead } from './src/head-morpher.ts';
|
|
16
|
-
|
|
17
|
-
export type { PageState } from './src/navigation.ts';
|
package/src/adapter.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Router adapter for React integration.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { ReactRouterAdapter } from '@ecopages/react/router-adapter';
|
|
7
|
-
import type { EcoRouterOptions } from './types.ts';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Creates a ReactRouterAdapter for EcoPages React Router.
|
|
11
|
-
* Use this with the React plugin to enable SPA navigation.
|
|
12
|
-
*
|
|
13
|
-
* @param options - Router configuration options
|
|
14
|
-
* @example
|
|
15
|
-
* ```ts
|
|
16
|
-
* import { reactPlugin } from '@ecopages/react';
|
|
17
|
-
* import { ecoRouter } from '@ecopages/react-router';
|
|
18
|
-
*
|
|
19
|
-
* export default {
|
|
20
|
-
* integrations: [reactPlugin({ router: ecoRouter() })],
|
|
21
|
-
* };
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```ts
|
|
26
|
-
* // Disable view transitions
|
|
27
|
-
* reactPlugin({ router: ecoRouter({ viewTransitions: false }) })
|
|
28
|
-
* ```
|
|
29
|
-
*/
|
|
30
|
-
export function ecoRouter(options?: EcoRouterOptions): ReactRouterAdapter {
|
|
31
|
-
return {
|
|
32
|
-
name: 'eco-router',
|
|
33
|
-
bundle: {
|
|
34
|
-
importPath: '@ecopages/react-router/browser.ts',
|
|
35
|
-
outputName: 'react-router-esm',
|
|
36
|
-
externals: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
|
37
|
-
},
|
|
38
|
-
importMapKey: '@ecopages/react-router',
|
|
39
|
-
components: {
|
|
40
|
-
router: 'EcoRouter',
|
|
41
|
-
pageContent: 'PageContent',
|
|
42
|
-
},
|
|
43
|
-
getRouterProps(page: string, props: string): string {
|
|
44
|
-
const optionsStr = options ? `, options: ${JSON.stringify(options)}` : '';
|
|
45
|
-
return `{ page: ${page}, pageProps: ${props}${optionsStr} }`;
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
package/src/context.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Router context and hook for accessing navigation state.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createContext, useContext } from 'react';
|
|
7
|
-
|
|
8
|
-
export type RouterContextValue = {
|
|
9
|
-
navigate: (url: string) => void;
|
|
10
|
-
isNavigating: boolean;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export const RouterContext = createContext<RouterContextValue | null>(null);
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Hook to access the router's navigate function and navigation state.
|
|
17
|
-
* Must be used within an EcoRouter.
|
|
18
|
-
*
|
|
19
|
-
* @throws Error if used outside of EcoRouter
|
|
20
|
-
*/
|
|
21
|
-
export const useRouter = (): RouterContextValue => {
|
|
22
|
-
const context = useContext(RouterContext);
|
|
23
|
-
if (!context) throw new Error('useRouter must be used within EcoRouter');
|
|
24
|
-
return context;
|
|
25
|
-
};
|
package/src/head-morpher.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Head morphing utilities for client-side navigation.
|
|
3
|
-
* Intelligently syncs head elements between pages using key-based diffing.
|
|
4
|
-
* @module
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const PRESERVE_SELECTORS = ['script[type="importmap"]', 'meta[charset]', '[data-eco-persist]'];
|
|
8
|
-
|
|
9
|
-
function isNonExecutableHeadScript(el: Element): boolean {
|
|
10
|
-
if (el.tagName !== 'SCRIPT') {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const type = (el.getAttribute('type') ?? '').trim().toLowerCase();
|
|
15
|
-
if (!type) {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return ![
|
|
20
|
-
'application/javascript',
|
|
21
|
-
'application/ecmascript',
|
|
22
|
-
'module',
|
|
23
|
-
'text/ecmascript',
|
|
24
|
-
'text/javascript',
|
|
25
|
-
].includes(type);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function shouldPersistExecutableInlineHeadScript(el: Element): boolean {
|
|
29
|
-
if (el.tagName !== 'SCRIPT') {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const scriptId = el.getAttribute('data-eco-script-id') || el.getAttribute('id');
|
|
34
|
-
if (!scriptId) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (el.hasAttribute('data-eco-rerun')) {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if ((el as HTMLScriptElement).src) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return !isNonExecutableHeadScript(el);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Computes a unique key for a head element to enable diffing.
|
|
51
|
-
* Elements with the same key are considered the same across navigations.
|
|
52
|
-
*/
|
|
53
|
-
function getHeadElementKey(el: Element): string | null {
|
|
54
|
-
const tag = el.tagName.toLowerCase();
|
|
55
|
-
|
|
56
|
-
switch (tag) {
|
|
57
|
-
case 'title':
|
|
58
|
-
return 'title';
|
|
59
|
-
|
|
60
|
-
case 'meta': {
|
|
61
|
-
const name = el.getAttribute('name') || el.getAttribute('property') || el.getAttribute('http-equiv');
|
|
62
|
-
return name ? `meta:${name}` : null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
case 'link': {
|
|
66
|
-
const rel = el.getAttribute('rel');
|
|
67
|
-
const href = el.getAttribute('href');
|
|
68
|
-
if (rel === 'stylesheet' && href) return `stylesheet:${href}`;
|
|
69
|
-
if (rel === 'icon' || rel === 'shortcut icon') return 'favicon';
|
|
70
|
-
if (rel === 'canonical') return 'canonical';
|
|
71
|
-
return href ? `link:${href}` : null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
case 'script': {
|
|
75
|
-
if (el.getAttribute('type') === 'importmap') return 'importmap';
|
|
76
|
-
const scriptId = el.getAttribute('data-eco-script-id') || el.getAttribute('id');
|
|
77
|
-
if (scriptId) return `script-id:${scriptId}`;
|
|
78
|
-
const src = (el as HTMLScriptElement).src;
|
|
79
|
-
return src ? `script:${src}` : null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
case 'style': {
|
|
83
|
-
const dataId = el.getAttribute('data-eco-style');
|
|
84
|
-
return dataId ? `style:${dataId}` : null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
default:
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Morphs the current document head to match the new document's head.
|
|
94
|
-
* Now splits the process into adding new elements and returning a cleanup function
|
|
95
|
-
* to remove old ones. This is crucial for View Transitions to ensure styles
|
|
96
|
-
* don't disappear before the "old" snapshot is taken.
|
|
97
|
-
*
|
|
98
|
-
* @param newDocument - The parsed document from the navigation target
|
|
99
|
-
* @returns Promise that resolves to a cleanup function when new stylesheets have loaded
|
|
100
|
-
*/
|
|
101
|
-
export async function morphHead(newDocument: Document): Promise<() => void> {
|
|
102
|
-
const currentHead = document.head;
|
|
103
|
-
const newHead = newDocument.head;
|
|
104
|
-
|
|
105
|
-
const currentElements = new Map<string, Element>();
|
|
106
|
-
const newElements = new Map<string, Element>();
|
|
107
|
-
const stylesheetPromises: Promise<void>[] = [];
|
|
108
|
-
const elementsToRemove: Element[] = [];
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* First, map existing head elements by their keys
|
|
112
|
-
* to enable efficient diffing.
|
|
113
|
-
*/
|
|
114
|
-
for (const el of Array.from(currentHead.children)) {
|
|
115
|
-
const key = getHeadElementKey(el);
|
|
116
|
-
if (key) currentElements.set(key, el);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Next, map new head elements by their keys.
|
|
121
|
-
* This allows us to see which elements are new, updated, or removed.
|
|
122
|
-
*/
|
|
123
|
-
for (const el of Array.from(newHead.children)) {
|
|
124
|
-
const key = getHeadElementKey(el);
|
|
125
|
-
if (key) newElements.set(key, el);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Now, iterate over new elements to add or update them in the current head.
|
|
130
|
-
*/
|
|
131
|
-
for (const [key, newEl] of newElements) {
|
|
132
|
-
const currentEl = currentElements.get(key);
|
|
133
|
-
|
|
134
|
-
if (!currentEl) {
|
|
135
|
-
const src = newEl.getAttribute('src');
|
|
136
|
-
/**
|
|
137
|
-
* Skip hydration scripts during SPA navigation to prevent re-mounting
|
|
138
|
-
*
|
|
139
|
-
* In an SPA transition, the EcoRouter is already running and handling the page update.
|
|
140
|
-
* The new page's HTML includes a hydration script (for initial load support), but
|
|
141
|
-
* if we let it execute now, it would re-bootstrap the React app from scratch,
|
|
142
|
-
* causing a full re-mount, state loss, and a visual flash.
|
|
143
|
-
*
|
|
144
|
-
* By blocking this script, we ensure the router maintains control and state.
|
|
145
|
-
*/
|
|
146
|
-
if (newEl.tagName === 'SCRIPT' && src && src.includes('hydration.js') && src.includes('ecopages-react')) {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const cloned = newEl.cloneNode(true) as Element;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* If the new element is a stylesheet, we need to wait for it to load
|
|
154
|
-
* before considering the head morph complete. This prevents FOUC.
|
|
155
|
-
*/
|
|
156
|
-
if (cloned.tagName === 'LINK' && (cloned as HTMLLinkElement).rel === 'stylesheet') {
|
|
157
|
-
const loadPromise = new Promise<void>((resolve) => {
|
|
158
|
-
(cloned as HTMLLinkElement).onload = () => resolve();
|
|
159
|
-
(cloned as HTMLLinkElement).onerror = () => resolve();
|
|
160
|
-
});
|
|
161
|
-
stylesheetPromises.push(loadPromise);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
currentHead.appendChild(cloned);
|
|
165
|
-
} else if (key === 'title' && currentEl.textContent !== newEl.textContent) {
|
|
166
|
-
currentEl.textContent = newEl.textContent;
|
|
167
|
-
} else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
|
|
168
|
-
currentEl.textContent = newEl.textContent;
|
|
169
|
-
} else if (key.startsWith('style:') && currentEl.textContent !== newEl.textContent) {
|
|
170
|
-
currentEl.textContent = newEl.textContent;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Finally, handle any new elements without keys (e.g., inline scripts/styles)
|
|
176
|
-
*/
|
|
177
|
-
for (const newEl of Array.from(newHead.children)) {
|
|
178
|
-
const key = getHeadElementKey(newEl);
|
|
179
|
-
if (!key) {
|
|
180
|
-
currentHead.appendChild(newEl.cloneNode(true));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Wait for all new stylesheets to load before proceeding.
|
|
186
|
-
*/
|
|
187
|
-
if (stylesheetPromises.length > 0) {
|
|
188
|
-
await Promise.all(stylesheetPromises);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Identify and prepare to remove any old elements
|
|
193
|
-
* that are no longer present in the new head.
|
|
194
|
-
*/
|
|
195
|
-
for (const [key, el] of currentElements) {
|
|
196
|
-
if (!newElements.has(key)) {
|
|
197
|
-
const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
|
|
198
|
-
if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
|
|
199
|
-
elementsToRemove.push(el);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Return a cleanup function to remove old elements.
|
|
206
|
-
* This allows the caller to control when the removal happens,
|
|
207
|
-
* which is important for View Transitions.
|
|
208
|
-
*/
|
|
209
|
-
return () => {
|
|
210
|
-
for (const el of elementsToRemove) {
|
|
211
|
-
el.remove();
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EcoPages React Router - SPA navigation for React with SSR support.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export { EcoRouter, PageContent } from './router.ts';
|
|
7
|
-
export type { EcoRouterProps } from './router.ts';
|
|
8
|
-
|
|
9
|
-
export { EcoPropsScript } from './props-script.ts';
|
|
10
|
-
export type { EcoPropsScriptProps } from './props-script.ts';
|
|
11
|
-
|
|
12
|
-
export { useRouter } from './context.ts';
|
|
13
|
-
export type { RouterContextValue } from './context.ts';
|
|
14
|
-
|
|
15
|
-
export type { EcoRouterOptions } from './types.ts';
|
|
16
|
-
|
|
17
|
-
export { morphHead } from './head-morpher.ts';
|
|
18
|
-
|
|
19
|
-
export type { PageState } from './navigation.ts';
|
|
20
|
-
|
|
21
|
-
export { ecoRouter } from './adapter.ts';
|
package/src/manage-scroll.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Manages scroll position during navigations
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { EcoRouterOptions } from './types.ts';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Service for handling scroll position during page transitions.
|
|
10
|
-
* Handles window scroll behavior and hash navigation.
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* Handle window scroll position based on scrollBehavior option.
|
|
14
|
-
* Hash links always scroll to target regardless of option.
|
|
15
|
-
*/
|
|
16
|
-
export function manageScroll(
|
|
17
|
-
newUrl: URL,
|
|
18
|
-
previousUrl: URL,
|
|
19
|
-
options: {
|
|
20
|
-
scrollBehavior: Required<EcoRouterOptions>['scrollBehavior'];
|
|
21
|
-
smoothScroll: boolean;
|
|
22
|
-
},
|
|
23
|
-
): void {
|
|
24
|
-
const { scrollBehavior, smoothScroll } = options;
|
|
25
|
-
|
|
26
|
-
if (newUrl.hash) {
|
|
27
|
-
const target = document.getElementById(newUrl.hash.slice(1));
|
|
28
|
-
target?.scrollIntoView({ behavior: smoothScroll ? 'smooth' : 'instant' });
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const behavior = smoothScroll ? 'smooth' : 'instant';
|
|
33
|
-
|
|
34
|
-
switch (scrollBehavior) {
|
|
35
|
-
case 'preserve':
|
|
36
|
-
break;
|
|
37
|
-
case 'auto':
|
|
38
|
-
if (newUrl.pathname !== previousUrl.pathname) {
|
|
39
|
-
window.scrollTo({ top: 0, left: 0, behavior });
|
|
40
|
-
}
|
|
41
|
-
break;
|
|
42
|
-
case 'top':
|
|
43
|
-
default:
|
|
44
|
-
window.scrollTo({ top: 0, left: 0, behavior });
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
}
|