@affectively/aeon-pages 1.3.0
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 +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeleton Hydration - Client-Side Swap
|
|
3
|
+
*
|
|
4
|
+
* Handles the smooth transition from skeleton to real content.
|
|
5
|
+
* Designed to be inlined in <head> for instant execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Skeleton swap options */
|
|
9
|
+
export interface SkeletonSwapOptions {
|
|
10
|
+
/** Enable cross-fade animation */
|
|
11
|
+
fade?: boolean;
|
|
12
|
+
/** Fade duration in milliseconds */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** Callback when swap completes */
|
|
15
|
+
onComplete?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Skeleton state for the current page */
|
|
19
|
+
interface SkeletonState {
|
|
20
|
+
skeletonRoot: HTMLElement | null;
|
|
21
|
+
contentRoot: HTMLElement | null;
|
|
22
|
+
swapped: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state: SkeletonState = {
|
|
26
|
+
skeletonRoot: null,
|
|
27
|
+
contentRoot: null,
|
|
28
|
+
swapped: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Initialize skeleton system
|
|
33
|
+
* Called immediately in <head> before body renders
|
|
34
|
+
*/
|
|
35
|
+
export function initSkeleton(): void {
|
|
36
|
+
// Find skeleton and content containers
|
|
37
|
+
state.skeletonRoot = document.getElementById('aeon-skeleton');
|
|
38
|
+
state.contentRoot = document.getElementById('root');
|
|
39
|
+
|
|
40
|
+
if (!state.skeletonRoot || !state.contentRoot) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Hide content, show skeleton
|
|
45
|
+
state.contentRoot.style.display = 'none';
|
|
46
|
+
state.skeletonRoot.style.display = 'block';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Swap skeleton with real content
|
|
51
|
+
* Called when content is ready to render
|
|
52
|
+
*/
|
|
53
|
+
export function swapToContent(options: SkeletonSwapOptions = {}): void {
|
|
54
|
+
if (state.swapped || !state.skeletonRoot || !state.contentRoot) {
|
|
55
|
+
options.onComplete?.();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { fade = true, duration = 150, onComplete } = options;
|
|
60
|
+
|
|
61
|
+
if (fade) {
|
|
62
|
+
// Cross-fade animation
|
|
63
|
+
const transitionStyle = `opacity ${duration}ms ease-out`;
|
|
64
|
+
state.skeletonRoot.style.transition = transitionStyle;
|
|
65
|
+
state.contentRoot.style.transition = transitionStyle;
|
|
66
|
+
state.contentRoot.style.opacity = '0';
|
|
67
|
+
state.contentRoot.style.display = 'block';
|
|
68
|
+
|
|
69
|
+
// Force reflow to ensure transition works
|
|
70
|
+
void state.contentRoot.offsetHeight;
|
|
71
|
+
|
|
72
|
+
// Start transition
|
|
73
|
+
state.skeletonRoot.style.opacity = '0';
|
|
74
|
+
state.contentRoot.style.opacity = '1';
|
|
75
|
+
|
|
76
|
+
// Cleanup after transition
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
state.skeletonRoot?.remove();
|
|
79
|
+
onComplete?.();
|
|
80
|
+
}, duration);
|
|
81
|
+
} else {
|
|
82
|
+
// Instant swap
|
|
83
|
+
state.skeletonRoot.remove();
|
|
84
|
+
state.contentRoot.style.display = 'block';
|
|
85
|
+
onComplete?.();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
state.swapped = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if skeleton is still visible
|
|
93
|
+
*/
|
|
94
|
+
export function isSkeletonVisible(): boolean {
|
|
95
|
+
return !state.swapped && state.skeletonRoot !== null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate minified inline init script for <head>
|
|
100
|
+
* This script executes before body renders, ensuring skeleton shows first
|
|
101
|
+
*/
|
|
102
|
+
export function generateSkeletonInitScript(): string {
|
|
103
|
+
return `<script>
|
|
104
|
+
(function(){
|
|
105
|
+
var s=document.getElementById('aeon-skeleton'),r=document.getElementById('root');
|
|
106
|
+
if(s&&r){r.style.display='none';s.style.display='block'}
|
|
107
|
+
window.__AEON_SKELETON__={
|
|
108
|
+
swap:function(o){
|
|
109
|
+
if(this.done)return;
|
|
110
|
+
o=o||{};
|
|
111
|
+
var f=o.fade!==false,d=o.duration||150;
|
|
112
|
+
if(f){
|
|
113
|
+
s.style.transition=r.style.transition='opacity '+d+'ms ease-out';
|
|
114
|
+
r.style.opacity='0';r.style.display='block';
|
|
115
|
+
void r.offsetHeight;
|
|
116
|
+
s.style.opacity='0';r.style.opacity='1';
|
|
117
|
+
setTimeout(function(){s.remove();o.onComplete&&o.onComplete()},d);
|
|
118
|
+
}else{
|
|
119
|
+
s.remove();r.style.display='block';o.onComplete&&o.onComplete();
|
|
120
|
+
}
|
|
121
|
+
this.done=true
|
|
122
|
+
},
|
|
123
|
+
isVisible:function(){return!this.done&&!!s},
|
|
124
|
+
done:false
|
|
125
|
+
};
|
|
126
|
+
})();
|
|
127
|
+
</script>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate the complete HTML structure for skeleton-first rendering
|
|
132
|
+
*/
|
|
133
|
+
export function generateSkeletonPageStructure(options: {
|
|
134
|
+
title: string;
|
|
135
|
+
description?: string;
|
|
136
|
+
skeletonHtml: string;
|
|
137
|
+
skeletonCss: string;
|
|
138
|
+
contentHtml: string;
|
|
139
|
+
contentCss: string;
|
|
140
|
+
headExtra?: string;
|
|
141
|
+
bodyExtra?: string;
|
|
142
|
+
}): string {
|
|
143
|
+
const {
|
|
144
|
+
title,
|
|
145
|
+
description,
|
|
146
|
+
skeletonHtml,
|
|
147
|
+
skeletonCss,
|
|
148
|
+
contentHtml,
|
|
149
|
+
contentCss,
|
|
150
|
+
headExtra = '',
|
|
151
|
+
bodyExtra = '',
|
|
152
|
+
} = options;
|
|
153
|
+
|
|
154
|
+
const descriptionMeta = description
|
|
155
|
+
? `\n <meta name="description" content="${escapeHtml(description)}">`
|
|
156
|
+
: '';
|
|
157
|
+
|
|
158
|
+
return `<!DOCTYPE html>
|
|
159
|
+
<html lang="en">
|
|
160
|
+
<head>
|
|
161
|
+
<meta charset="UTF-8">
|
|
162
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
163
|
+
<title>${escapeHtml(title)}</title>${descriptionMeta}
|
|
164
|
+
<style>
|
|
165
|
+
/* Skeleton CSS */
|
|
166
|
+
${skeletonCss}
|
|
167
|
+
/* Content CSS */
|
|
168
|
+
${contentCss}
|
|
169
|
+
</style>
|
|
170
|
+
${generateSkeletonInitScript()}
|
|
171
|
+
${headExtra}
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div id="aeon-skeleton" aria-hidden="true">${skeletonHtml}</div>
|
|
175
|
+
<div id="root" style="display:none">${contentHtml}</div>
|
|
176
|
+
<script>
|
|
177
|
+
// Swap when DOM is ready
|
|
178
|
+
if(document.readyState==='loading'){
|
|
179
|
+
document.addEventListener('DOMContentLoaded',function(){
|
|
180
|
+
window.__AEON_SKELETON__.swap({fade:true});
|
|
181
|
+
});
|
|
182
|
+
}else{
|
|
183
|
+
window.__AEON_SKELETON__.swap({fade:true});
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
${bodyExtra}
|
|
187
|
+
</body>
|
|
188
|
+
</html>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate skeleton swap script for async content loading
|
|
193
|
+
* Use this when content loads after initial page render
|
|
194
|
+
*/
|
|
195
|
+
export function generateAsyncSwapScript(): string {
|
|
196
|
+
return `<script>
|
|
197
|
+
(function(){
|
|
198
|
+
// Wait for content to be ready (e.g., after React hydration)
|
|
199
|
+
function checkReady(){
|
|
200
|
+
var root=document.getElementById('root');
|
|
201
|
+
if(root&&root.children.length>0){
|
|
202
|
+
window.__AEON_SKELETON__&&window.__AEON_SKELETON__.swap({fade:true});
|
|
203
|
+
}else{
|
|
204
|
+
requestAnimationFrame(checkReady);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if(document.readyState==='loading'){
|
|
208
|
+
document.addEventListener('DOMContentLoaded',checkReady);
|
|
209
|
+
}else{
|
|
210
|
+
checkReady();
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
</script>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Escape HTML special characters
|
|
218
|
+
*/
|
|
219
|
+
function escapeHtml(str: string): string {
|
|
220
|
+
return str
|
|
221
|
+
.replace(/&/g, '&')
|
|
222
|
+
.replace(/</g, '<')
|
|
223
|
+
.replace(/>/g, '>')
|
|
224
|
+
.replace(/"/g, '"')
|
|
225
|
+
.replace(/'/g, ''');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// TypeScript declaration for the global skeleton API
|
|
229
|
+
declare global {
|
|
230
|
+
interface Window {
|
|
231
|
+
__AEON_SKELETON__?: {
|
|
232
|
+
swap: (options?: SkeletonSwapOptions) => void;
|
|
233
|
+
isVisible: () => boolean;
|
|
234
|
+
done: boolean;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
expect,
|
|
5
|
+
beforeEach,
|
|
6
|
+
afterEach,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
} from 'bun:test';
|
|
10
|
+
import {
|
|
11
|
+
SpeculativeRenderer,
|
|
12
|
+
getSpeculativeRenderer,
|
|
13
|
+
setSpeculativeRenderer,
|
|
14
|
+
initSpeculativeRendering,
|
|
15
|
+
type SpeculativeRendererConfig,
|
|
16
|
+
} from './speculation';
|
|
17
|
+
import { setPredictor, NavigationPredictor } from './predictor';
|
|
18
|
+
|
|
19
|
+
// Mock DOM environment
|
|
20
|
+
function createMockDOM() {
|
|
21
|
+
return {
|
|
22
|
+
querySelectorAll: mock(() => []),
|
|
23
|
+
addEventListener: mock(() => {}),
|
|
24
|
+
createElement: mock(() => ({
|
|
25
|
+
type: '',
|
|
26
|
+
textContent: '',
|
|
27
|
+
})),
|
|
28
|
+
head: {
|
|
29
|
+
appendChild: mock(() => {}),
|
|
30
|
+
},
|
|
31
|
+
open: mock(() => {}),
|
|
32
|
+
write: mock(() => {}),
|
|
33
|
+
close: mock(() => {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createMockWindow() {
|
|
38
|
+
return {
|
|
39
|
+
location: { pathname: '/', origin: 'http://localhost' },
|
|
40
|
+
history: {
|
|
41
|
+
pushState: mock(() => {}),
|
|
42
|
+
},
|
|
43
|
+
addEventListener: mock(() => {}),
|
|
44
|
+
IntersectionObserver: mock((callback: Function, options: any) => ({
|
|
45
|
+
observe: mock(() => {}),
|
|
46
|
+
unobserve: mock(() => {}),
|
|
47
|
+
disconnect: mock(() => {}),
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('SpeculativeRenderer', () => {
|
|
53
|
+
let originalWindow: typeof globalThis.window;
|
|
54
|
+
let originalDocument: typeof globalThis.document;
|
|
55
|
+
let originalFetch: typeof globalThis.fetch;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
originalWindow = globalThis.window;
|
|
59
|
+
originalDocument = globalThis.document;
|
|
60
|
+
originalFetch = globalThis.fetch;
|
|
61
|
+
|
|
62
|
+
// Set up a mock predictor
|
|
63
|
+
const predictor = new NavigationPredictor();
|
|
64
|
+
setPredictor(predictor);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
globalThis.window = originalWindow;
|
|
69
|
+
globalThis.document = originalDocument;
|
|
70
|
+
globalThis.fetch = originalFetch;
|
|
71
|
+
setSpeculativeRenderer(null as any);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('creates instance with default config', () => {
|
|
75
|
+
const renderer = new SpeculativeRenderer();
|
|
76
|
+
expect(renderer).toBeDefined();
|
|
77
|
+
|
|
78
|
+
const stats = renderer.getStats();
|
|
79
|
+
expect(stats.cachedPages).toBe(0);
|
|
80
|
+
expect(stats.cacheSize).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('creates instance with custom config', () => {
|
|
84
|
+
const config: Partial<SpeculativeRendererConfig> = {
|
|
85
|
+
maxCachedPages: 10,
|
|
86
|
+
maxCacheSize: 10 * 1024 * 1024,
|
|
87
|
+
minConfidence: 0.5,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const renderer = new SpeculativeRenderer(config);
|
|
91
|
+
expect(renderer).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('getSpeculativeRenderer returns singleton', () => {
|
|
95
|
+
const renderer1 = getSpeculativeRenderer();
|
|
96
|
+
const renderer2 = getSpeculativeRenderer();
|
|
97
|
+
expect(renderer1).toBe(renderer2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('setSpeculativeRenderer replaces singleton', () => {
|
|
101
|
+
const original = getSpeculativeRenderer();
|
|
102
|
+
const replacement = new SpeculativeRenderer();
|
|
103
|
+
|
|
104
|
+
setSpeculativeRenderer(replacement);
|
|
105
|
+
expect(getSpeculativeRenderer()).toBe(replacement);
|
|
106
|
+
expect(getSpeculativeRenderer()).not.toBe(original);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('prerender caches page HTML', async () => {
|
|
110
|
+
const mockHtml = '<html><body>Test Page</body></html>';
|
|
111
|
+
|
|
112
|
+
globalThis.fetch = mock(
|
|
113
|
+
async () =>
|
|
114
|
+
({
|
|
115
|
+
ok: true,
|
|
116
|
+
text: async () => mockHtml,
|
|
117
|
+
}) as Response,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
globalThis.window = {
|
|
121
|
+
location: { pathname: '/' },
|
|
122
|
+
} as any;
|
|
123
|
+
|
|
124
|
+
const renderer = new SpeculativeRenderer();
|
|
125
|
+
const result = await renderer.prerender('/about');
|
|
126
|
+
|
|
127
|
+
expect(result).toBe(true);
|
|
128
|
+
expect(renderer.getStats().cachedPages).toBe(1);
|
|
129
|
+
expect(renderer.getStats().cacheSize).toBe(mockHtml.length);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('prerender skips current route', async () => {
|
|
133
|
+
globalThis.window = {
|
|
134
|
+
location: { pathname: '/about' },
|
|
135
|
+
} as any;
|
|
136
|
+
|
|
137
|
+
const renderer = new SpeculativeRenderer();
|
|
138
|
+
const result = await renderer.prerender('/about');
|
|
139
|
+
|
|
140
|
+
expect(result).toBe(false);
|
|
141
|
+
expect(renderer.getStats().cachedPages).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('prerender handles fetch failures', async () => {
|
|
145
|
+
globalThis.fetch = mock(
|
|
146
|
+
async () =>
|
|
147
|
+
({
|
|
148
|
+
ok: false,
|
|
149
|
+
status: 404,
|
|
150
|
+
}) as Response,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
globalThis.window = {
|
|
154
|
+
location: { pathname: '/' },
|
|
155
|
+
} as any;
|
|
156
|
+
|
|
157
|
+
const renderer = new SpeculativeRenderer();
|
|
158
|
+
const result = await renderer.prerender('/not-found');
|
|
159
|
+
|
|
160
|
+
expect(result).toBe(false);
|
|
161
|
+
expect(renderer.getStats().cachedPages).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('prerender handles network errors', async () => {
|
|
165
|
+
globalThis.fetch = mock(async () => {
|
|
166
|
+
throw new Error('Network error');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
globalThis.window = {
|
|
170
|
+
location: { pathname: '/' },
|
|
171
|
+
} as any;
|
|
172
|
+
|
|
173
|
+
const renderer = new SpeculativeRenderer();
|
|
174
|
+
const result = await renderer.prerender('/error');
|
|
175
|
+
|
|
176
|
+
expect(result).toBe(false);
|
|
177
|
+
expect(renderer.getStats().cachedPages).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('invalidate marks pages as stale', async () => {
|
|
181
|
+
const mockHtml = '<html><body>Test</body></html>';
|
|
182
|
+
|
|
183
|
+
globalThis.fetch = mock(
|
|
184
|
+
async () =>
|
|
185
|
+
({
|
|
186
|
+
ok: true,
|
|
187
|
+
text: async () => mockHtml,
|
|
188
|
+
}) as Response,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
globalThis.window = {
|
|
192
|
+
location: { pathname: '/' },
|
|
193
|
+
} as any;
|
|
194
|
+
|
|
195
|
+
const renderer = new SpeculativeRenderer();
|
|
196
|
+
await renderer.prerender('/about');
|
|
197
|
+
await renderer.prerender('/contact');
|
|
198
|
+
|
|
199
|
+
// Invalidate specific routes
|
|
200
|
+
renderer.invalidate(['/about']);
|
|
201
|
+
|
|
202
|
+
// About should be stale, navigate should fail
|
|
203
|
+
const navigateResult = await renderer.navigate('/about');
|
|
204
|
+
expect(navigateResult).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('invalidate without routes marks all as stale', async () => {
|
|
208
|
+
const mockHtml = '<html><body>Test</body></html>';
|
|
209
|
+
|
|
210
|
+
globalThis.fetch = mock(
|
|
211
|
+
async () =>
|
|
212
|
+
({
|
|
213
|
+
ok: true,
|
|
214
|
+
text: async () => mockHtml,
|
|
215
|
+
}) as Response,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
globalThis.window = {
|
|
219
|
+
location: { pathname: '/' },
|
|
220
|
+
} as any;
|
|
221
|
+
|
|
222
|
+
const renderer = new SpeculativeRenderer();
|
|
223
|
+
await renderer.prerender('/about');
|
|
224
|
+
await renderer.prerender('/contact');
|
|
225
|
+
|
|
226
|
+
// Invalidate all
|
|
227
|
+
renderer.invalidate();
|
|
228
|
+
|
|
229
|
+
// Both should be stale
|
|
230
|
+
const aboutResult = await renderer.navigate('/about');
|
|
231
|
+
const contactResult = await renderer.navigate('/contact');
|
|
232
|
+
|
|
233
|
+
expect(aboutResult).toBe(false);
|
|
234
|
+
expect(contactResult).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('evicts old pages when cache is full', async () => {
|
|
238
|
+
const mockHtml = 'x'.repeat(1000); // 1KB per page
|
|
239
|
+
|
|
240
|
+
globalThis.fetch = mock(
|
|
241
|
+
async () =>
|
|
242
|
+
({
|
|
243
|
+
ok: true,
|
|
244
|
+
text: async () => mockHtml,
|
|
245
|
+
}) as Response,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
globalThis.window = {
|
|
249
|
+
location: { pathname: '/' },
|
|
250
|
+
} as any;
|
|
251
|
+
|
|
252
|
+
// Small cache - max 2 pages
|
|
253
|
+
const renderer = new SpeculativeRenderer({
|
|
254
|
+
maxCachedPages: 2,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await renderer.prerender('/page1');
|
|
258
|
+
await renderer.prerender('/page2');
|
|
259
|
+
|
|
260
|
+
expect(renderer.getStats().cachedPages).toBe(2);
|
|
261
|
+
|
|
262
|
+
// Adding third page should evict oldest
|
|
263
|
+
await renderer.prerender('/page3');
|
|
264
|
+
|
|
265
|
+
expect(renderer.getStats().cachedPages).toBe(2);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('evicts based on cache size limit', async () => {
|
|
269
|
+
globalThis.fetch = mock(
|
|
270
|
+
async () =>
|
|
271
|
+
({
|
|
272
|
+
ok: true,
|
|
273
|
+
text: async () => 'x'.repeat(1000), // 1KB
|
|
274
|
+
}) as Response,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
globalThis.window = {
|
|
278
|
+
location: { pathname: '/' },
|
|
279
|
+
} as any;
|
|
280
|
+
|
|
281
|
+
// Small cache - max 1.5KB
|
|
282
|
+
const renderer = new SpeculativeRenderer({
|
|
283
|
+
maxCacheSize: 1500,
|
|
284
|
+
maxCachedPages: 100,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await renderer.prerender('/page1');
|
|
288
|
+
await renderer.prerender('/page2');
|
|
289
|
+
|
|
290
|
+
// Should only keep one page due to size limit
|
|
291
|
+
expect(renderer.getStats().cachedPages).toBe(1);
|
|
292
|
+
expect(renderer.getStats().cacheSize).toBeLessThanOrEqual(1500);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('uses cached page on second prerender', async () => {
|
|
296
|
+
let fetchCount = 0;
|
|
297
|
+
const mockHtml = '<html><body>Test</body></html>';
|
|
298
|
+
|
|
299
|
+
globalThis.fetch = mock(async () => {
|
|
300
|
+
fetchCount++;
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
text: async () => mockHtml,
|
|
304
|
+
} as Response;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
globalThis.window = {
|
|
308
|
+
location: { pathname: '/' },
|
|
309
|
+
} as any;
|
|
310
|
+
|
|
311
|
+
const renderer = new SpeculativeRenderer();
|
|
312
|
+
|
|
313
|
+
await renderer.prerender('/about');
|
|
314
|
+
expect(fetchCount).toBe(1);
|
|
315
|
+
|
|
316
|
+
// Second call should use cache
|
|
317
|
+
await renderer.prerender('/about');
|
|
318
|
+
expect(fetchCount).toBe(1); // No additional fetch
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('destroy cleans up resources', async () => {
|
|
322
|
+
const mockHtml = '<html><body>Test</body></html>';
|
|
323
|
+
|
|
324
|
+
globalThis.fetch = mock(
|
|
325
|
+
async () =>
|
|
326
|
+
({
|
|
327
|
+
ok: true,
|
|
328
|
+
text: async () => mockHtml,
|
|
329
|
+
}) as Response,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
globalThis.window = {
|
|
333
|
+
location: { pathname: '/' },
|
|
334
|
+
} as any;
|
|
335
|
+
|
|
336
|
+
const renderer = new SpeculativeRenderer();
|
|
337
|
+
await renderer.prerender('/about');
|
|
338
|
+
|
|
339
|
+
expect(renderer.getStats().cachedPages).toBe(1);
|
|
340
|
+
|
|
341
|
+
renderer.destroy();
|
|
342
|
+
|
|
343
|
+
expect(renderer.getStats().cachedPages).toBe(0);
|
|
344
|
+
expect(renderer.getStats().cacheSize).toBe(0);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('initSpeculativeRendering', () => {
|
|
349
|
+
test('creates and initializes renderer', () => {
|
|
350
|
+
// Can't fully test init without DOM, but verify function exists
|
|
351
|
+
expect(initSpeculativeRendering).toBeDefined();
|
|
352
|
+
expect(typeof initSpeculativeRendering).toBe('function');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('Speculation integration with predictor', () => {
|
|
357
|
+
test('predictor predictions are used for pre-rendering', () => {
|
|
358
|
+
const predictor = new NavigationPredictor();
|
|
359
|
+
|
|
360
|
+
// Record some navigation history
|
|
361
|
+
predictor.record({
|
|
362
|
+
from: '/',
|
|
363
|
+
to: '/dashboard',
|
|
364
|
+
timestamp: Date.now(),
|
|
365
|
+
duration: 5000,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
predictor.record({
|
|
369
|
+
from: '/',
|
|
370
|
+
to: '/dashboard',
|
|
371
|
+
timestamp: Date.now(),
|
|
372
|
+
duration: 3000,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
predictor.record({
|
|
376
|
+
from: '/',
|
|
377
|
+
to: '/explore',
|
|
378
|
+
timestamp: Date.now(),
|
|
379
|
+
duration: 2000,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Predict from home
|
|
383
|
+
const predictions = predictor.predict('/');
|
|
384
|
+
|
|
385
|
+
expect(predictions.length).toBeGreaterThan(0);
|
|
386
|
+
expect(predictions[0].route).toBe('/dashboard'); // Most likely
|
|
387
|
+
expect(predictions[0].probability).toBeGreaterThan(0.4); // ~0.48 expected
|
|
388
|
+
});
|
|
389
|
+
});
|