melina 1.0.2 → 1.0.3
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 +1 -1
- package/src/web.ts +3 -3
- package/src/runtime/hangar.ts +0 -399
package/package.json
CHANGED
package/src/web.ts
CHANGED
|
@@ -444,8 +444,8 @@ const builtAssets: Record<string, { content: ArrayBuffer; contentType: string }>
|
|
|
444
444
|
let cachedRuntimePath: string | null = null;
|
|
445
445
|
|
|
446
446
|
/**
|
|
447
|
-
* Build the Melina
|
|
448
|
-
* This bundles src/
|
|
447
|
+
* Build the Melina client runtime from TypeScript source
|
|
448
|
+
* This bundles src/client.ts and serves it from memory
|
|
449
449
|
*/
|
|
450
450
|
async function buildRuntime(): Promise<string> {
|
|
451
451
|
// Return cached path if available
|
|
@@ -454,7 +454,7 @@ async function buildRuntime(): Promise<string> {
|
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
// Find the runtime source file (in the package, not the user's app)
|
|
457
|
-
const runtimePath = path.resolve(__dirname, './
|
|
457
|
+
const runtimePath = path.resolve(__dirname, './client.ts');
|
|
458
458
|
|
|
459
459
|
if (!existsSync(runtimePath)) {
|
|
460
460
|
throw new Error(`Melina runtime not found at: ${runtimePath}`);
|
package/src/runtime/hangar.ts
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Melina.js "Hangar" Runtime
|
|
3
|
-
*
|
|
4
|
-
* Single Hidden Root + Portal Registry Architecture
|
|
5
|
-
*
|
|
6
|
-
* This runtime manages client-side islands using a persistent React root
|
|
7
|
-
* that survives body.innerHTML replacements during navigation.
|
|
8
|
-
*
|
|
9
|
-
* Key concepts:
|
|
10
|
-
* - Hangar: The container appended to `<html>` (outside body), never destroyed
|
|
11
|
-
* - Island Registry: Map of instanceId -> { Component, props, targetNode, storageNode }
|
|
12
|
-
* - Storage Node: Persistent div that physically moves between placeholders
|
|
13
|
-
* - Physical Reparenting: DOM nodes are moved, not recreated, preserving all state
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
declare global {
|
|
17
|
-
interface Window {
|
|
18
|
-
__MELINA_META__?: Record<string, string>;
|
|
19
|
-
melinaNavigate: (href: string) => Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ========== TYPES ==========
|
|
24
|
-
interface IslandEntry {
|
|
25
|
-
name: string;
|
|
26
|
-
Component: React.ComponentType<any>;
|
|
27
|
-
props: Record<string, any>;
|
|
28
|
-
targetNode: Element;
|
|
29
|
-
storageNode: HTMLDivElement;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ========== STATE ==========
|
|
33
|
-
const componentCache: Record<string, React.ComponentType<any>> = {};
|
|
34
|
-
const islandRegistry = new Map<string, IslandEntry>();
|
|
35
|
-
const registryListeners = new Set<() => void>();
|
|
36
|
-
|
|
37
|
-
function notifyRegistry() {
|
|
38
|
-
registryListeners.forEach(fn => fn());
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ========== UTILITIES ==========
|
|
42
|
-
function getIslandMeta(): Record<string, string> {
|
|
43
|
-
const metaEl = document.getElementById('__MELINA_META__');
|
|
44
|
-
if (!metaEl) return {};
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(metaEl.textContent || '{}');
|
|
47
|
-
} catch {
|
|
48
|
-
return {};
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function loadComponent(name: string): Promise<React.ComponentType<any> | null> {
|
|
53
|
-
if (componentCache[name]) return componentCache[name];
|
|
54
|
-
|
|
55
|
-
const meta = getIslandMeta();
|
|
56
|
-
if (!meta[name]) return null;
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const module = await import(/* @vite-ignore */ meta[name]);
|
|
60
|
-
componentCache[name] = module[name] || module.default;
|
|
61
|
-
return componentCache[name];
|
|
62
|
-
} catch (e) {
|
|
63
|
-
console.error('[Melina] Failed to load', name, e);
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ========== PRE-LOAD MODULES ==========
|
|
69
|
-
// Load all island components upfront to prevent pop-in
|
|
70
|
-
async function preloadModules(): Promise<void> {
|
|
71
|
-
const meta = getIslandMeta();
|
|
72
|
-
const names = Object.keys(meta);
|
|
73
|
-
|
|
74
|
-
await Promise.all(names.map(name => loadComponent(name)));
|
|
75
|
-
console.log('[Melina] Pre-loaded', names.length, 'island modules');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ========== HYDRATE ISLANDS ==========
|
|
79
|
-
// Scans DOM for placeholders, registers/updates them in registry
|
|
80
|
-
async function hydrateIslands(): Promise<void> {
|
|
81
|
-
const placeholders = document.querySelectorAll('[data-melina-island]');
|
|
82
|
-
const seenIds = new Set<string>();
|
|
83
|
-
|
|
84
|
-
for (let i = 0; i < placeholders.length; i++) {
|
|
85
|
-
const el = placeholders[i] as Element;
|
|
86
|
-
const name = el.getAttribute('data-melina-island');
|
|
87
|
-
if (!name) continue;
|
|
88
|
-
|
|
89
|
-
const propsStr = (el.getAttribute('data-props') || '{}').replace(/"/g, '"');
|
|
90
|
-
const props = JSON.parse(propsStr);
|
|
91
|
-
const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
|
|
92
|
-
|
|
93
|
-
seenIds.add(instanceId);
|
|
94
|
-
|
|
95
|
-
const existing = islandRegistry.get(instanceId);
|
|
96
|
-
|
|
97
|
-
if (existing) {
|
|
98
|
-
// Island exists - update targetNode and MOVE storageNode physically
|
|
99
|
-
existing.targetNode = el;
|
|
100
|
-
existing.props = props;
|
|
101
|
-
|
|
102
|
-
// Physical Reparenting: Move the storage node to new placeholder
|
|
103
|
-
el.appendChild(existing.storageNode);
|
|
104
|
-
console.log('[Melina] Moved island:', instanceId);
|
|
105
|
-
} else {
|
|
106
|
-
// New island - load component and register
|
|
107
|
-
const Component = await loadComponent(name);
|
|
108
|
-
if (Component) {
|
|
109
|
-
// Create persistent storage node (the "lifeboat")
|
|
110
|
-
const storageNode = document.createElement('div');
|
|
111
|
-
storageNode.style.display = 'contents';
|
|
112
|
-
storageNode.setAttribute('data-storage', instanceId);
|
|
113
|
-
el.appendChild(storageNode);
|
|
114
|
-
|
|
115
|
-
islandRegistry.set(instanceId, {
|
|
116
|
-
name,
|
|
117
|
-
Component,
|
|
118
|
-
props,
|
|
119
|
-
targetNode: el,
|
|
120
|
-
storageNode
|
|
121
|
-
});
|
|
122
|
-
console.log('[Melina] Registered island:', instanceId);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Garbage collection: Remove islands that no longer have placeholders
|
|
128
|
-
for (const [id, item] of islandRegistry.entries()) {
|
|
129
|
-
if (!seenIds.has(id)) {
|
|
130
|
-
// Move to holding pen or just delete
|
|
131
|
-
const holdingPen = document.getElementById('melina-holding-pen');
|
|
132
|
-
if (holdingPen) {
|
|
133
|
-
holdingPen.appendChild(item.storageNode);
|
|
134
|
-
}
|
|
135
|
-
islandRegistry.delete(id);
|
|
136
|
-
console.log('[Melina] Collected island:', id);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
notifyRegistry();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ========== SYNC ISLANDS (synchronous) ==========
|
|
144
|
-
// Re-scans DOM for placeholders, updates targets for existing islands
|
|
145
|
-
// This is SYNC so it can run inside View Transition callback
|
|
146
|
-
function syncIslands(): void {
|
|
147
|
-
console.log('[Melina] syncIslands called, registry size:', islandRegistry.size);
|
|
148
|
-
const placeholders = document.querySelectorAll('[data-melina-island]');
|
|
149
|
-
console.log('[Melina] Found placeholders:', placeholders.length);
|
|
150
|
-
|
|
151
|
-
for (let i = 0; i < placeholders.length; i++) {
|
|
152
|
-
const el = placeholders[i] as Element;
|
|
153
|
-
const name = el.getAttribute('data-melina-island');
|
|
154
|
-
if (!name) continue;
|
|
155
|
-
|
|
156
|
-
const propsStr = (el.getAttribute('data-props') || '{}').replace(/"/g, '"');
|
|
157
|
-
const props = JSON.parse(propsStr);
|
|
158
|
-
const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
|
|
159
|
-
|
|
160
|
-
console.log('[Melina] Looking for island:', instanceId, 'in registry');
|
|
161
|
-
|
|
162
|
-
const existing = islandRegistry.get(instanceId);
|
|
163
|
-
|
|
164
|
-
if (existing) {
|
|
165
|
-
// CRITICAL: Update targetNode to NEW DOM element and move storageNode
|
|
166
|
-
console.log('[Melina] Found island in registry, moving storageNode');
|
|
167
|
-
existing.targetNode = el;
|
|
168
|
-
existing.props = props;
|
|
169
|
-
el.appendChild(existing.storageNode);
|
|
170
|
-
console.log('[Melina] Moved island:', instanceId, 'storageNode children:', existing.storageNode.childNodes.length);
|
|
171
|
-
} else {
|
|
172
|
-
console.log('[Melina] Island NOT found in registry:', instanceId);
|
|
173
|
-
console.log('[Melina] Registry keys:', Array.from(islandRegistry.keys()));
|
|
174
|
-
}
|
|
175
|
-
// New islands are ignored here - they need async component loading
|
|
176
|
-
// which happens after transition via hydrateIslands()
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
notifyRegistry(); // Triggers React re-render immediately
|
|
180
|
-
console.log('[Melina] syncIslands complete');
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ========== ISLAND MANAGER COMPONENT ==========
|
|
184
|
-
async function initializeHangar(): Promise<void> {
|
|
185
|
-
const React = await import('react');
|
|
186
|
-
const ReactDOM = await import('react-dom/client');
|
|
187
|
-
const { createPortal } = await import('react-dom');
|
|
188
|
-
|
|
189
|
-
// Pre-load all island modules first
|
|
190
|
-
await preloadModules();
|
|
191
|
-
|
|
192
|
-
// Create hangar root OUTSIDE body (survives body.innerHTML replacement)
|
|
193
|
-
let hangar = document.getElementById('melina-hangar');
|
|
194
|
-
if (!hangar) {
|
|
195
|
-
hangar = document.createElement('div');
|
|
196
|
-
hangar.id = 'melina-hangar';
|
|
197
|
-
hangar.style.display = 'contents';
|
|
198
|
-
document.documentElement.appendChild(hangar);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Holding pen for garbage collected islands (temporary storage)
|
|
202
|
-
let holdingPen = document.getElementById('melina-holding-pen');
|
|
203
|
-
if (!holdingPen) {
|
|
204
|
-
holdingPen = document.createElement('div');
|
|
205
|
-
holdingPen.id = 'melina-holding-pen';
|
|
206
|
-
holdingPen.style.display = 'none';
|
|
207
|
-
hangar.appendChild(holdingPen);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Root container for React
|
|
211
|
-
let rootContainer = document.getElementById('melina-hangar-root');
|
|
212
|
-
if (!rootContainer) {
|
|
213
|
-
rootContainer = document.createElement('div');
|
|
214
|
-
rootContainer.id = 'melina-hangar-root';
|
|
215
|
-
rootContainer.style.display = 'contents';
|
|
216
|
-
hangar.appendChild(rootContainer);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Island Manager Component
|
|
220
|
-
function IslandManager(): React.ReactNode {
|
|
221
|
-
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
|
|
222
|
-
|
|
223
|
-
// Subscribe to registry changes
|
|
224
|
-
React.useEffect(() => {
|
|
225
|
-
registryListeners.add(forceUpdate);
|
|
226
|
-
return () => { registryListeners.delete(forceUpdate); };
|
|
227
|
-
}, []);
|
|
228
|
-
|
|
229
|
-
// Render all registered islands as portals INTO their storageNodes
|
|
230
|
-
const portals: React.ReactNode[] = [];
|
|
231
|
-
islandRegistry.forEach((island, instanceId) => {
|
|
232
|
-
if (island.storageNode && island.storageNode.isConnected) {
|
|
233
|
-
portals.push(
|
|
234
|
-
createPortal(
|
|
235
|
-
React.createElement(island.Component, {
|
|
236
|
-
...island.props,
|
|
237
|
-
key: instanceId
|
|
238
|
-
}),
|
|
239
|
-
island.storageNode,
|
|
240
|
-
instanceId
|
|
241
|
-
)
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return React.createElement(React.Fragment, null, portals);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Create single React root
|
|
250
|
-
const root = ReactDOM.createRoot(rootContainer);
|
|
251
|
-
root.render(React.createElement(IslandManager));
|
|
252
|
-
|
|
253
|
-
// Initial hydration
|
|
254
|
-
await hydrateIslands();
|
|
255
|
-
|
|
256
|
-
console.log('[Melina] Hangar initialized with', islandRegistry.size, 'islands');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ========== NAVIGATION ==========
|
|
260
|
-
async function navigate(href: string): Promise<void> {
|
|
261
|
-
const fromPath = window.location.pathname;
|
|
262
|
-
const toPath = new URL(href, window.location.origin).pathname;
|
|
263
|
-
if (fromPath === toPath) return;
|
|
264
|
-
|
|
265
|
-
console.log('[Melina] Navigate start:', fromPath, '->', toPath);
|
|
266
|
-
|
|
267
|
-
// Step 1: FETCH new page content FIRST (before any transitions)
|
|
268
|
-
let newDoc: Document;
|
|
269
|
-
try {
|
|
270
|
-
const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
|
|
271
|
-
const htmlText = await response.text();
|
|
272
|
-
const parser = new DOMParser();
|
|
273
|
-
newDoc = parser.parseFromString(htmlText, 'text/html');
|
|
274
|
-
console.log('[Melina] Fetched new page, body length:', newDoc.body.innerHTML.length);
|
|
275
|
-
} catch (error) {
|
|
276
|
-
console.error('[Melina] Fetch failed:', error);
|
|
277
|
-
window.location.href = href;
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Step 2: Update URL (but DON'T dispatch navigation event yet!)
|
|
282
|
-
// The event must fire INSIDE the View Transition callback so the
|
|
283
|
-
// "old" snapshot captures the current state before the update
|
|
284
|
-
window.history.pushState({}, '', href);
|
|
285
|
-
|
|
286
|
-
// Step 3: SYNC update function - dispatches event INSIDE for proper animation
|
|
287
|
-
const performUpdate = () => {
|
|
288
|
-
// CRITICAL: Dispatch navigation-start INSIDE the View Transition callback
|
|
289
|
-
// This way:
|
|
290
|
-
// 1. "Old" snapshot was already taken (mini player)
|
|
291
|
-
// 2. Event fires → MusicPlayer flushSync updates to expanded view
|
|
292
|
-
// 3. DOM swap happens
|
|
293
|
-
// 4. "New" snapshot is taken (expanded player)
|
|
294
|
-
// 5. Browser animates between old and new!
|
|
295
|
-
window.dispatchEvent(new CustomEvent('melina:navigation-start', {
|
|
296
|
-
detail: { from: fromPath, to: toPath }
|
|
297
|
-
}));
|
|
298
|
-
|
|
299
|
-
document.title = newDoc.title;
|
|
300
|
-
document.body.innerHTML = newDoc.body.innerHTML;
|
|
301
|
-
console.log('[Melina] Full body swap');
|
|
302
|
-
syncIslands();
|
|
303
|
-
window.scrollTo(0, 0);
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
// Start View Transition with SYNC callback
|
|
308
|
-
if (document.startViewTransition) {
|
|
309
|
-
console.log('[Melina] Starting View Transition');
|
|
310
|
-
const transition = document.startViewTransition(performUpdate);
|
|
311
|
-
await transition.finished;
|
|
312
|
-
console.log('[Melina] View Transition finished');
|
|
313
|
-
} else {
|
|
314
|
-
performUpdate();
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// After transition, hydrate any NEW islands
|
|
318
|
-
await hydrateIslands();
|
|
319
|
-
console.log('[Melina] Navigation complete:', fromPath, '->', toPath);
|
|
320
|
-
|
|
321
|
-
} catch (error) {
|
|
322
|
-
console.error('[Melina] Navigation failed:', error);
|
|
323
|
-
window.location.href = href;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Expose navigate globally
|
|
328
|
-
window.melinaNavigate = navigate;
|
|
329
|
-
|
|
330
|
-
// ========== LINK INTERCEPTION ==========
|
|
331
|
-
document.addEventListener('click', (e: MouseEvent) => {
|
|
332
|
-
// Skip if already handled by another handler (e.g., Link component)
|
|
333
|
-
if (e.defaultPrevented) return;
|
|
334
|
-
|
|
335
|
-
const link = (e.target as Element).closest('a[href]') as HTMLAnchorElement | null;
|
|
336
|
-
if (!link) return;
|
|
337
|
-
|
|
338
|
-
const href = link.getAttribute('href');
|
|
339
|
-
if (
|
|
340
|
-
e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ||
|
|
341
|
-
link.hasAttribute('download') || link.hasAttribute('target') ||
|
|
342
|
-
link.hasAttribute('data-no-intercept') ||
|
|
343
|
-
!href || !href.startsWith('/')
|
|
344
|
-
) return;
|
|
345
|
-
|
|
346
|
-
if (window.location.pathname === href) {
|
|
347
|
-
e.preventDefault();
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
e.preventDefault();
|
|
352
|
-
navigate(href);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// ========== POPSTATE ==========
|
|
356
|
-
window.addEventListener('popstate', async () => {
|
|
357
|
-
const href = window.location.pathname;
|
|
358
|
-
|
|
359
|
-
// Core update logic - runs INSIDE View Transition callback
|
|
360
|
-
const performUpdate = async () => {
|
|
361
|
-
const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
|
|
362
|
-
const html = await response.text();
|
|
363
|
-
const newDoc = new DOMParser().parseFromString(html, 'text/html');
|
|
364
|
-
|
|
365
|
-
document.title = newDoc.title;
|
|
366
|
-
|
|
367
|
-
// FULL BODY REPLACEMENT
|
|
368
|
-
document.body.innerHTML = newDoc.body.innerHTML;
|
|
369
|
-
|
|
370
|
-
// SYNC: Find island placeholders, move portals
|
|
371
|
-
syncIslands();
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
// Start View Transition - captures old state, then animates to new
|
|
376
|
-
if (document.startViewTransition) {
|
|
377
|
-
await document.startViewTransition(performUpdate).finished;
|
|
378
|
-
await hydrateIslands();
|
|
379
|
-
} else {
|
|
380
|
-
await performUpdate();
|
|
381
|
-
await hydrateIslands();
|
|
382
|
-
}
|
|
383
|
-
} catch (e) {
|
|
384
|
-
console.error('[Melina] Popstate failed:', e);
|
|
385
|
-
window.location.reload();
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// ========== INIT ==========
|
|
390
|
-
if (document.readyState === 'loading') {
|
|
391
|
-
document.addEventListener('DOMContentLoaded', initializeHangar);
|
|
392
|
-
} else {
|
|
393
|
-
initializeHangar();
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
console.log('[Melina] Hangar Runtime loaded');
|
|
397
|
-
|
|
398
|
-
// Export for testing
|
|
399
|
-
export { hydrateIslands, syncIslands, navigate, islandRegistry };
|