@ecopages/core 0.2.0-beta.32 → 0.2.0-beta.34
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/package.json +11 -3
- package/src/router/README.md +23 -0
- package/src/router/client/navigation-lifecycle.d.ts +55 -0
- package/src/router/client/navigation-lifecycle.js +50 -0
- package/src/router/client/navigation-lifecycle.test.browser.d.ts +1 -0
- package/src/router/client/navigation-lifecycle.test.browser.js +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/core",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.34",
|
|
4
4
|
"description": "Core package for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"directory": "packages/core"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@ecopages/file-system": "0.2.0-beta.
|
|
20
|
+
"@ecopages/file-system": "0.2.0-beta.34",
|
|
21
21
|
"@ecopages/logger": "^0.2.3",
|
|
22
22
|
"@ecopages/scripts-injector": "^0.1.5",
|
|
23
23
|
"@oxc-project/runtime": "0.134.0",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@standard-schema/utils": "^0.3.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@ecopages/dev-toolbar": "0.2.0-beta.
|
|
35
|
+
"@ecopages/dev-toolbar": "0.2.0-beta.34"
|
|
36
36
|
},
|
|
37
37
|
"peerDependenciesMeta": {
|
|
38
38
|
"@ecopages/dev-toolbar": {
|
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
"types": "./src/router/client/navigation-coordinator.d.ts",
|
|
89
89
|
"default": "./src/router/client/navigation-coordinator.js"
|
|
90
90
|
},
|
|
91
|
+
"./router/navigation-lifecycle": {
|
|
92
|
+
"types": "./src/router/client/navigation-lifecycle.d.ts",
|
|
93
|
+
"default": "./src/router/client/navigation-lifecycle.js"
|
|
94
|
+
},
|
|
91
95
|
"./router/link-intent": {
|
|
92
96
|
"types": "./src/router/client/link-intent.d.ts",
|
|
93
97
|
"default": "./src/router/client/link-intent.js"
|
|
@@ -357,6 +361,10 @@
|
|
|
357
361
|
"types": "./src/router/client/navigation-coordinator.d.ts",
|
|
358
362
|
"default": "./src/router/client/navigation-coordinator.js"
|
|
359
363
|
},
|
|
364
|
+
"./router/navigation-lifecycle.ts": {
|
|
365
|
+
"types": "./src/router/client/navigation-lifecycle.d.ts",
|
|
366
|
+
"default": "./src/router/client/navigation-lifecycle.js"
|
|
367
|
+
},
|
|
360
368
|
"./router/link-intent.ts": {
|
|
361
369
|
"types": "./src/router/client/link-intent.d.ts",
|
|
362
370
|
"default": "./src/router/client/link-intent.js"
|
package/src/router/README.md
CHANGED
|
@@ -22,6 +22,7 @@ router/
|
|
|
22
22
|
│ └── route-registry.ts # Owns template routes, request matching, static expansion, and reload
|
|
23
23
|
└── client/ # Browser-side navigation coordination
|
|
24
24
|
├── navigation-coordinator.ts # Singleton runtime coordinator
|
|
25
|
+
├── navigation-lifecycle.ts # Shared document lifecycle events
|
|
25
26
|
└── link-intent.ts # Shared anchor detection and intent recovery helpers
|
|
26
27
|
```
|
|
27
28
|
|
|
@@ -79,6 +80,28 @@ The coordinator is framework-agnostic. Browser runtimes (e.g. `browser-router`,
|
|
|
79
80
|
- **Reload** — `reloadCurrentPage` delegates to whichever runtime currently owns the document.
|
|
80
81
|
- **Events** — `subscribe` lets runtimes react to `owner-change` and `registration-change` events.
|
|
81
82
|
|
|
83
|
+
### Navigation Lifecycle (`navigation-lifecycle.ts`)
|
|
84
|
+
|
|
85
|
+
Shared document lifecycle events emitted by browser runtimes after a client navigation commits.
|
|
86
|
+
|
|
87
|
+
Access it with:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import {
|
|
91
|
+
completeNavigationLifecycle,
|
|
92
|
+
dispatchBeforeSwap,
|
|
93
|
+
type EcoNavigationEvent,
|
|
94
|
+
} from '@ecopages/core/router/navigation-lifecycle';
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`browser-router` and `react-router` call these helpers around their own commit implementations. Use `completeNavigationLifecycle` for the paired post-commit signals. Listeners use the DOM contract:
|
|
98
|
+
|
|
99
|
+
- `eco:before-swap` — pre-commit hook with `newDocument` and `reload()`
|
|
100
|
+
- `eco:after-swap` — post-commit signal after the runtime has applied the new page
|
|
101
|
+
- `eco:page-load` — scheduled on the next animation frame after `eco:after-swap`
|
|
102
|
+
|
|
103
|
+
This is separate from coordinator `subscribe()` events, which track runtime ownership rather than page lifecycle.
|
|
104
|
+
|
|
82
105
|
### Link Intent (`link-intent.ts`)
|
|
83
106
|
|
|
84
107
|
Shared helpers for locating anchors and recovering stale navigation intent.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared document navigation lifecycle events for browser runtimes.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* `eco:before-swap`, `eco:after-swap`, and `eco:page-load` are the public
|
|
6
|
+
* contract consumed by dev tooling, global injectors, and layout scripts.
|
|
7
|
+
* Dispatch helpers live here so browser-router and react-router stay aligned.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import type { EcoNavigationDirection } from './navigation-coordinator.js';
|
|
12
|
+
export declare const ECO_NAVIGATION_LIFECYCLE_EVENTS: {
|
|
13
|
+
readonly BEFORE_SWAP: "eco:before-swap";
|
|
14
|
+
readonly AFTER_SWAP: "eco:after-swap";
|
|
15
|
+
readonly PAGE_LOAD: "eco:page-load";
|
|
16
|
+
};
|
|
17
|
+
/** Base detail shared by navigation lifecycle events. */
|
|
18
|
+
export interface EcoNavigationEvent {
|
|
19
|
+
url: URL;
|
|
20
|
+
direction: EcoNavigationDirection;
|
|
21
|
+
}
|
|
22
|
+
/** Detail for the pre-commit lifecycle event. */
|
|
23
|
+
export interface EcoBeforeSwapEvent extends EcoNavigationEvent {
|
|
24
|
+
newDocument: Document;
|
|
25
|
+
reload: () => void;
|
|
26
|
+
}
|
|
27
|
+
/** Detail for the post-commit lifecycle event. */
|
|
28
|
+
export interface EcoAfterSwapEvent extends EcoNavigationEvent {
|
|
29
|
+
}
|
|
30
|
+
/** Typed document event map for navigation lifecycle listeners. */
|
|
31
|
+
export interface EcoNavigationLifecycleEventMap {
|
|
32
|
+
'eco:before-swap': CustomEvent<EcoBeforeSwapEvent>;
|
|
33
|
+
'eco:after-swap': CustomEvent<EcoAfterSwapEvent>;
|
|
34
|
+
'eco:page-load': CustomEvent<EcoNavigationEvent>;
|
|
35
|
+
}
|
|
36
|
+
export type DispatchBeforeSwapResult = {
|
|
37
|
+
requestedReload: boolean;
|
|
38
|
+
};
|
|
39
|
+
export type SchedulePageLoadOptions = {
|
|
40
|
+
isStale?: () => boolean;
|
|
41
|
+
schedule?: (callback: FrameRequestCallback) => number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Dispatches `eco:before-swap` and reports whether a listener requested reload.
|
|
45
|
+
*/
|
|
46
|
+
export declare function dispatchBeforeSwap(doc: Document, input: Omit<EcoBeforeSwapEvent, 'reload'>): DispatchBeforeSwapResult;
|
|
47
|
+
/** Dispatches `eco:after-swap`. */
|
|
48
|
+
export declare function dispatchAfterSwap(doc: Document, detail: EcoAfterSwapEvent): void;
|
|
49
|
+
export type CompleteNavigationLifecycleOptions = SchedulePageLoadOptions;
|
|
50
|
+
/** Dispatches `eco:after-swap` and schedules `eco:page-load`. */
|
|
51
|
+
export declare function completeNavigationLifecycle(doc: Document, detail: EcoNavigationEvent, options?: CompleteNavigationLifecycleOptions): void;
|
|
52
|
+
/**
|
|
53
|
+
* Schedules `eco:page-load` on the next animation frame unless navigation is stale.
|
|
54
|
+
*/
|
|
55
|
+
export declare function schedulePageLoad(doc: Document, detail: EcoNavigationEvent, options?: SchedulePageLoadOptions): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared document navigation lifecycle events for browser runtimes.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* `eco:before-swap`, `eco:after-swap`, and `eco:page-load` are the public
|
|
6
|
+
* contract consumed by dev tooling, global injectors, and layout scripts.
|
|
7
|
+
* Dispatch helpers live here so browser-router and react-router stay aligned.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
export const ECO_NAVIGATION_LIFECYCLE_EVENTS = {
|
|
12
|
+
BEFORE_SWAP: 'eco:before-swap',
|
|
13
|
+
AFTER_SWAP: 'eco:after-swap',
|
|
14
|
+
PAGE_LOAD: 'eco:page-load',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Dispatches `eco:before-swap` and reports whether a listener requested reload.
|
|
18
|
+
*/
|
|
19
|
+
export function dispatchBeforeSwap(doc, input) {
|
|
20
|
+
let requestedReload = false;
|
|
21
|
+
const detail = {
|
|
22
|
+
...input,
|
|
23
|
+
reload: () => {
|
|
24
|
+
requestedReload = true;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
doc.dispatchEvent(new CustomEvent(ECO_NAVIGATION_LIFECYCLE_EVENTS.BEFORE_SWAP, { detail }));
|
|
28
|
+
return { requestedReload };
|
|
29
|
+
}
|
|
30
|
+
/** Dispatches `eco:after-swap`. */
|
|
31
|
+
export function dispatchAfterSwap(doc, detail) {
|
|
32
|
+
doc.dispatchEvent(new CustomEvent(ECO_NAVIGATION_LIFECYCLE_EVENTS.AFTER_SWAP, { detail }));
|
|
33
|
+
}
|
|
34
|
+
/** Dispatches `eco:after-swap` and schedules `eco:page-load`. */
|
|
35
|
+
export function completeNavigationLifecycle(doc, detail, options = {}) {
|
|
36
|
+
dispatchAfterSwap(doc, detail);
|
|
37
|
+
schedulePageLoad(doc, detail, options);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Schedules `eco:page-load` on the next animation frame unless navigation is stale.
|
|
41
|
+
*/
|
|
42
|
+
export function schedulePageLoad(doc, detail, options = {}) {
|
|
43
|
+
const schedule = options.schedule ?? ((callback) => requestAnimationFrame(callback));
|
|
44
|
+
schedule(() => {
|
|
45
|
+
if (options.isStale?.()) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
doc.dispatchEvent(new CustomEvent(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, { detail }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { completeNavigationLifecycle, dispatchAfterSwap, dispatchBeforeSwap, ECO_NAVIGATION_LIFECYCLE_EVENTS, schedulePageLoad, } from './navigation-lifecycle.js';
|
|
3
|
+
describe('navigation lifecycle dispatch', () => {
|
|
4
|
+
it('dispatches before-swap with newDocument and reload hook', () => {
|
|
5
|
+
const beforeSwapSpy = vi.fn();
|
|
6
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.BEFORE_SWAP, beforeSwapSpy);
|
|
7
|
+
try {
|
|
8
|
+
const newDocument = new DOMParser().parseFromString('<html><body>Next</body></html>', 'text/html');
|
|
9
|
+
const url = new URL('https://example.test/next');
|
|
10
|
+
const { requestedReload } = dispatchBeforeSwap(document, {
|
|
11
|
+
url,
|
|
12
|
+
direction: 'forward',
|
|
13
|
+
newDocument,
|
|
14
|
+
});
|
|
15
|
+
expect(requestedReload).toBe(false);
|
|
16
|
+
expect(beforeSwapSpy).toHaveBeenCalledTimes(1);
|
|
17
|
+
const detail = beforeSwapSpy.mock.calls[0][0].detail;
|
|
18
|
+
expect(detail.url).toBe(url);
|
|
19
|
+
expect(detail.direction).toBe('forward');
|
|
20
|
+
expect(detail.newDocument).toBe(newDocument);
|
|
21
|
+
expect(typeof detail.reload).toBe('function');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.BEFORE_SWAP, beforeSwapSpy);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('reports reload requests from before-swap listeners', () => {
|
|
28
|
+
const beforeSwapSpy = vi.fn((event) => {
|
|
29
|
+
event.detail.reload();
|
|
30
|
+
});
|
|
31
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.BEFORE_SWAP, beforeSwapSpy);
|
|
32
|
+
try {
|
|
33
|
+
const { requestedReload } = dispatchBeforeSwap(document, {
|
|
34
|
+
url: new URL('https://example.test/reload'),
|
|
35
|
+
direction: 'forward',
|
|
36
|
+
newDocument: document,
|
|
37
|
+
});
|
|
38
|
+
expect(requestedReload).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.BEFORE_SWAP, beforeSwapSpy);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it('dispatches after-swap with url and direction', () => {
|
|
45
|
+
const afterSwapSpy = vi.fn();
|
|
46
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.AFTER_SWAP, afterSwapSpy);
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL('https://example.test/after');
|
|
49
|
+
dispatchAfterSwap(document, { url, direction: 'replace' });
|
|
50
|
+
expect(afterSwapSpy).toHaveBeenCalledTimes(1);
|
|
51
|
+
const detail = afterSwapSpy.mock.calls[0][0].detail;
|
|
52
|
+
expect(detail.url).toBe(url);
|
|
53
|
+
expect(detail.direction).toBe('replace');
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.AFTER_SWAP, afterSwapSpy);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
it('completes navigation lifecycle with after-swap and page-load', () => {
|
|
60
|
+
const afterSwapSpy = vi.fn();
|
|
61
|
+
const pageLoadSpy = vi.fn();
|
|
62
|
+
const scheduleSpy = vi.fn((callback) => {
|
|
63
|
+
callback(0);
|
|
64
|
+
return 1;
|
|
65
|
+
});
|
|
66
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.AFTER_SWAP, afterSwapSpy);
|
|
67
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL('https://example.test/complete');
|
|
70
|
+
completeNavigationLifecycle(document, { url, direction: 'forward' }, { schedule: scheduleSpy });
|
|
71
|
+
expect(afterSwapSpy).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(pageLoadSpy).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(scheduleSpy).toHaveBeenCalledTimes(1);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.AFTER_SWAP, afterSwapSpy);
|
|
77
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
it('schedules page-load on the next animation frame', () => {
|
|
81
|
+
const pageLoadSpy = vi.fn();
|
|
82
|
+
const scheduleSpy = vi.fn((callback) => {
|
|
83
|
+
callback(0);
|
|
84
|
+
return 1;
|
|
85
|
+
});
|
|
86
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
87
|
+
try {
|
|
88
|
+
const url = new URL('https://example.test/page-load');
|
|
89
|
+
schedulePageLoad(document, { url, direction: 'forward' }, { schedule: scheduleSpy });
|
|
90
|
+
expect(scheduleSpy).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(pageLoadSpy).toHaveBeenCalledTimes(1);
|
|
92
|
+
const detail = pageLoadSpy.mock.calls[0][0].detail;
|
|
93
|
+
expect(detail.url).toBe(url);
|
|
94
|
+
expect(detail.direction).toBe('forward');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('skips page-load when navigation becomes stale before the frame callback', () => {
|
|
101
|
+
const pageLoadSpy = vi.fn();
|
|
102
|
+
let stale = true;
|
|
103
|
+
document.addEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
104
|
+
try {
|
|
105
|
+
schedulePageLoad(document, { url: new URL('https://example.test/stale'), direction: 'forward' }, {
|
|
106
|
+
isStale: () => stale,
|
|
107
|
+
schedule: (callback) => {
|
|
108
|
+
callback(0);
|
|
109
|
+
return 1;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
expect(pageLoadSpy).not.toHaveBeenCalled();
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
document.removeEventListener(ECO_NAVIGATION_LIFECYCLE_EVENTS.PAGE_LOAD, pageLoadSpy);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|