melina 1.0.2 → 1.0.4
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/client.ts +183 -0
- package/src/web.ts +6 -3
- package/src/runtime/hangar.ts +0 -399
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -918,6 +918,189 @@ function renderChildren(children: Child | Child[] | undefined, parentFiber: Fibe
|
|
|
918
918
|
}
|
|
919
919
|
}
|
|
920
920
|
|
|
921
|
+
// =============================================================================
|
|
922
|
+
// SERVER-SIDE RENDERING
|
|
923
|
+
// =============================================================================
|
|
924
|
+
|
|
925
|
+
// Void elements that don't have closing tags
|
|
926
|
+
const VOID_ELEMENTS = new Set([
|
|
927
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
928
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
// Map of React-style prop names to HTML attribute names
|
|
932
|
+
const PROP_TO_ATTR: Record<string, string> = {
|
|
933
|
+
className: 'class',
|
|
934
|
+
htmlFor: 'for',
|
|
935
|
+
tabIndex: 'tabindex',
|
|
936
|
+
readOnly: 'readonly',
|
|
937
|
+
maxLength: 'maxlength',
|
|
938
|
+
cellPadding: 'cellpadding',
|
|
939
|
+
cellSpacing: 'cellspacing',
|
|
940
|
+
colSpan: 'colspan',
|
|
941
|
+
rowSpan: 'rowspan',
|
|
942
|
+
srcSet: 'srcset',
|
|
943
|
+
useMap: 'usemap',
|
|
944
|
+
frameBorder: 'frameborder',
|
|
945
|
+
contentEditable: 'contenteditable',
|
|
946
|
+
crossOrigin: 'crossorigin',
|
|
947
|
+
dateTime: 'datetime',
|
|
948
|
+
encType: 'enctype',
|
|
949
|
+
formAction: 'formaction',
|
|
950
|
+
formEncType: 'formenctype',
|
|
951
|
+
formMethod: 'formmethod',
|
|
952
|
+
formNoValidate: 'formnovalidate',
|
|
953
|
+
formTarget: 'formtarget',
|
|
954
|
+
hrefLang: 'hreflang',
|
|
955
|
+
inputMode: 'inputmode',
|
|
956
|
+
noValidate: 'novalidate',
|
|
957
|
+
playsInline: 'playsinline',
|
|
958
|
+
autoComplete: 'autocomplete',
|
|
959
|
+
autoFocus: 'autofocus',
|
|
960
|
+
autoPlay: 'autoplay',
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Escape HTML special characters
|
|
965
|
+
*/
|
|
966
|
+
function escapeHtml(str: string): string {
|
|
967
|
+
return str
|
|
968
|
+
.replace(/&/g, '&')
|
|
969
|
+
.replace(/</g, '<')
|
|
970
|
+
.replace(/>/g, '>')
|
|
971
|
+
.replace(/"/g, '"')
|
|
972
|
+
.replace(/'/g, ''');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Convert a VNode tree to an HTML string (Server-Side Rendering)
|
|
977
|
+
* This is melina/client's equivalent to react-dom/server.renderToString()
|
|
978
|
+
*/
|
|
979
|
+
export function renderToString(vnode: VNode | Child): string {
|
|
980
|
+
// Handle null, undefined, booleans
|
|
981
|
+
if (vnode === null || vnode === undefined || vnode === true || vnode === false) {
|
|
982
|
+
return '';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Handle primitives (strings, numbers)
|
|
986
|
+
if (typeof vnode === 'string') {
|
|
987
|
+
return escapeHtml(vnode);
|
|
988
|
+
}
|
|
989
|
+
if (typeof vnode === 'number') {
|
|
990
|
+
return String(vnode);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Handle arrays
|
|
994
|
+
if (Array.isArray(vnode)) {
|
|
995
|
+
return vnode.map(child => renderToString(child)).join('');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const { type, props } = vnode as VNode;
|
|
999
|
+
|
|
1000
|
+
// Handle Fragment
|
|
1001
|
+
if (type === Fragment) {
|
|
1002
|
+
return renderChildrenToString(props.children);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Handle function components
|
|
1006
|
+
if (typeof type === 'function') {
|
|
1007
|
+
// For SSR, we just call the component function
|
|
1008
|
+
// Hooks will work because we set up currentFiber context
|
|
1009
|
+
const fiber: Fiber = {
|
|
1010
|
+
node: null,
|
|
1011
|
+
vnode: vnode as VNode,
|
|
1012
|
+
hooks: [],
|
|
1013
|
+
hookIndex: 0,
|
|
1014
|
+
parent: null,
|
|
1015
|
+
children: [],
|
|
1016
|
+
cleanup: [],
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const prevFiber = currentFiber;
|
|
1020
|
+
currentFiber = fiber;
|
|
1021
|
+
fiber.hookIndex = 0;
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
const result = (type as Component)(props);
|
|
1025
|
+
currentFiber = prevFiber;
|
|
1026
|
+
return renderToString(result);
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
currentFiber = prevFiber;
|
|
1029
|
+
throw e;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Handle HTML elements
|
|
1034
|
+
const tagName = type as string;
|
|
1035
|
+
let html = `<${tagName}`;
|
|
1036
|
+
|
|
1037
|
+
// Render attributes
|
|
1038
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1039
|
+
if (key === 'children' || key === 'key' || key === 'ref') continue;
|
|
1040
|
+
if (value === undefined || value === null || value === false) continue;
|
|
1041
|
+
|
|
1042
|
+
// Handle dangerouslySetInnerHTML
|
|
1043
|
+
if (key === 'dangerouslySetInnerHTML') continue;
|
|
1044
|
+
|
|
1045
|
+
// Handle style object
|
|
1046
|
+
if (key === 'style' && typeof value === 'object') {
|
|
1047
|
+
const styleStr = Object.entries(value)
|
|
1048
|
+
.map(([k, v]) => {
|
|
1049
|
+
// Convert camelCase to kebab-case
|
|
1050
|
+
const prop = k.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1051
|
+
return `${prop}:${v}`;
|
|
1052
|
+
})
|
|
1053
|
+
.join(';');
|
|
1054
|
+
html += ` style="${escapeHtml(styleStr)}"`;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Skip event handlers on server
|
|
1059
|
+
if (key.startsWith('on') && typeof value === 'function') continue;
|
|
1060
|
+
|
|
1061
|
+
// Convert prop name to attribute name
|
|
1062
|
+
const attrName = PROP_TO_ATTR[key] || key.toLowerCase();
|
|
1063
|
+
|
|
1064
|
+
// Boolean attributes
|
|
1065
|
+
if (value === true) {
|
|
1066
|
+
html += ` ${attrName}`;
|
|
1067
|
+
} else {
|
|
1068
|
+
html += ` ${attrName}="${escapeHtml(String(value))}"`;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
html += '>';
|
|
1073
|
+
|
|
1074
|
+
// Void elements don't have children or closing tags
|
|
1075
|
+
if (VOID_ELEMENTS.has(tagName)) {
|
|
1076
|
+
return html;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Handle dangerouslySetInnerHTML
|
|
1080
|
+
if (props.dangerouslySetInnerHTML) {
|
|
1081
|
+
html += props.dangerouslySetInnerHTML.__html;
|
|
1082
|
+
} else {
|
|
1083
|
+
// Render children
|
|
1084
|
+
html += renderChildrenToString(props.children);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
html += `</${tagName}>`;
|
|
1088
|
+
return html;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Render children to string
|
|
1093
|
+
*/
|
|
1094
|
+
function renderChildrenToString(children: Child | Child[] | undefined): string {
|
|
1095
|
+
if (children === undefined || children === null) return '';
|
|
1096
|
+
|
|
1097
|
+
if (Array.isArray(children)) {
|
|
1098
|
+
return children.map(child => renderToString(child)).join('');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return renderToString(children);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
921
1104
|
// =============================================================================
|
|
922
1105
|
// ISLAND SYSTEM (HANGAR ARCHITECTURE)
|
|
923
1106
|
// =============================================================================
|
package/src/web.ts
CHANGED
|
@@ -444,8 +444,11 @@ 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
|
+
*
|
|
450
|
+
* The client runtime is React-free - it uses melina/client's lightweight VDOM.
|
|
451
|
+
* SSR uses React on the server, but the browser only loads melina/client.
|
|
449
452
|
*/
|
|
450
453
|
async function buildRuntime(): Promise<string> {
|
|
451
454
|
// Return cached path if available
|
|
@@ -454,7 +457,7 @@ async function buildRuntime(): Promise<string> {
|
|
|
454
457
|
}
|
|
455
458
|
|
|
456
459
|
// Find the runtime source file (in the package, not the user's app)
|
|
457
|
-
const runtimePath = path.resolve(__dirname, './
|
|
460
|
+
const runtimePath = path.resolve(__dirname, './client.ts');
|
|
458
461
|
|
|
459
462
|
if (!existsSync(runtimePath)) {
|
|
460
463
|
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 };
|