@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. 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, '&amp;')
222
+ .replace(/</g, '&lt;')
223
+ .replace(/>/g, '&gt;')
224
+ .replace(/"/g, '&quot;')
225
+ .replace(/'/g, '&#039;');
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
+ });