@tschk/moonshine-react 0.4.1 → 0.4.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 +3 -3
- package/src/islands.tsx +85 -16
- package/src/renderer.tsx +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-react",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "React renderer, islands, and signal hooks for Moonshine",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tschk/moonshine": "^0.4.
|
|
20
|
-
"@tschk/moonshine-framework": "^0.4.
|
|
19
|
+
"@tschk/moonshine": "^0.4.4",
|
|
20
|
+
"@tschk/moonshine-framework": "^0.4.4",
|
|
21
21
|
"react": "^19.1.0",
|
|
22
22
|
"react-dom": "^19.1.0"
|
|
23
23
|
},
|
package/src/islands.tsx
CHANGED
|
@@ -14,6 +14,7 @@ export type IslandEntry = {
|
|
|
14
14
|
id: string;
|
|
15
15
|
specifier: string;
|
|
16
16
|
props: Record<string, unknown>;
|
|
17
|
+
hydrate?: "load" | "idle" | "visible";
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export type IslandRegistry = {
|
|
@@ -21,6 +22,7 @@ export type IslandRegistry = {
|
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const IslandContext = createContext<IslandRegistry | null>(null);
|
|
25
|
+
const hydrationStates = new WeakMap<Element, { hydrated: boolean }>();
|
|
24
26
|
|
|
25
27
|
const ISLAND_IMPORT_RE = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
26
28
|
|
|
@@ -54,8 +56,10 @@ export const IslandProvider: FunctionComponent<{
|
|
|
54
56
|
|
|
55
57
|
export function island<P>(
|
|
56
58
|
loader: () => Promise<ComponentType<P> | { default: ComponentType<P> }>,
|
|
59
|
+
options: { hydrate?: "load" | "idle" | "visible" } = {},
|
|
57
60
|
): FunctionComponent<P> {
|
|
58
|
-
const
|
|
61
|
+
const hydrate = options.hydrate ?? "load";
|
|
62
|
+
const id = getIslandId(loader) + (hydrate === "load" ? "" : `-${hydrate}`);
|
|
59
63
|
const specifier = extractSpecifier(loader);
|
|
60
64
|
const IslandComponent: FunctionComponent<P> = (props) => {
|
|
61
65
|
const registry = useContext(IslandContext);
|
|
@@ -71,12 +75,20 @@ export function island<P>(
|
|
|
71
75
|
id,
|
|
72
76
|
specifier,
|
|
73
77
|
props: (props ?? {}) as Record<string, unknown>,
|
|
78
|
+
hydrate,
|
|
74
79
|
});
|
|
75
80
|
}
|
|
76
81
|
return createElement(
|
|
77
82
|
Fragment,
|
|
78
83
|
null,
|
|
79
|
-
createElement(
|
|
84
|
+
createElement(
|
|
85
|
+
"div",
|
|
86
|
+
{
|
|
87
|
+
"data-moonshine-island": id,
|
|
88
|
+
"data-moonshine-hydrate": hydrate === "load" ? undefined : hydrate,
|
|
89
|
+
},
|
|
90
|
+
children,
|
|
91
|
+
),
|
|
80
92
|
createElement(
|
|
81
93
|
"script",
|
|
82
94
|
{
|
|
@@ -90,31 +102,88 @@ export function island<P>(
|
|
|
90
102
|
return IslandComponent;
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
export function hydrateIslands(): void {
|
|
94
|
-
if (typeof document === "undefined") return;
|
|
105
|
+
export function hydrateIslands(): () => void {
|
|
106
|
+
if (typeof document === "undefined") return () => {};
|
|
107
|
+
let disposed = false;
|
|
108
|
+
const cleanups: Array<() => void> = [];
|
|
95
109
|
const modulesEl = document.querySelector(
|
|
96
110
|
"script[data-moonshine-island-modules]",
|
|
97
111
|
);
|
|
98
112
|
const modules = modulesEl ? JSON.parse(modulesEl.textContent ?? "{}") : {};
|
|
99
113
|
const markers = document.querySelectorAll("[data-moonshine-island]");
|
|
100
114
|
for (const marker of Array.from(markers)) {
|
|
115
|
+
if (hydrationStates.has(marker)) continue;
|
|
101
116
|
const id = marker.getAttribute("data-moonshine-island");
|
|
102
117
|
if (!id) continue;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
const sibling = marker.nextElementSibling;
|
|
119
|
+
const propsEl =
|
|
120
|
+
sibling?.getAttribute("data-moonshine-island-props") === id
|
|
121
|
+
? sibling
|
|
122
|
+
: document.querySelector(`script[data-moonshine-island-props="${id}"]`);
|
|
106
123
|
const props = propsEl ? JSON.parse(propsEl.textContent ?? "{}") : {};
|
|
107
124
|
const specifier = modules[id];
|
|
108
125
|
if (!specifier) continue;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.
|
|
114
|
-
hydrateRoot(
|
|
115
|
-
marker,
|
|
116
|
-
createElement(Component, props as Record<string, unknown>),
|
|
117
|
-
);
|
|
126
|
+
const state = { hydrated: false };
|
|
127
|
+
hydrationStates.set(marker, state);
|
|
128
|
+
cleanups.push(() => {
|
|
129
|
+
if (!state.hydrated && hydrationStates.get(marker) === state)
|
|
130
|
+
hydrationStates.delete(marker);
|
|
118
131
|
});
|
|
132
|
+
let started = false;
|
|
133
|
+
const hydrate = () => {
|
|
134
|
+
if (disposed || started || !marker.isConnected) return;
|
|
135
|
+
started = true;
|
|
136
|
+
return import(specifier)
|
|
137
|
+
.then(async (mod) => {
|
|
138
|
+
const client = await import("react-dom/client");
|
|
139
|
+
const { hydrateRoot } = client.default ?? client;
|
|
140
|
+
if (disposed || !marker.isConnected) return;
|
|
141
|
+
const Component = ((mod as { default?: ComponentType<unknown> })
|
|
142
|
+
.default ?? mod) as FunctionComponent<unknown>;
|
|
143
|
+
hydrateRoot(
|
|
144
|
+
marker,
|
|
145
|
+
createElement(Component, props as Record<string, unknown>),
|
|
146
|
+
);
|
|
147
|
+
state.hydrated = true;
|
|
148
|
+
})
|
|
149
|
+
.catch((error: unknown) => {
|
|
150
|
+
if (hydrationStates.get(marker) === state)
|
|
151
|
+
hydrationStates.delete(marker);
|
|
152
|
+
if (disposed) return;
|
|
153
|
+
if (typeof reportError === "function") reportError(error);
|
|
154
|
+
else console.error("Moonshine island hydration failed", error);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const mode = marker.getAttribute("data-moonshine-hydrate");
|
|
158
|
+
if (mode === "visible" && typeof IntersectionObserver !== "undefined") {
|
|
159
|
+
const observer = new IntersectionObserver((entries) => {
|
|
160
|
+
if (disposed || !entries.some((entry) => entry.isIntersecting)) return;
|
|
161
|
+
observer.disconnect();
|
|
162
|
+
void hydrate();
|
|
163
|
+
});
|
|
164
|
+
observer.observe(marker);
|
|
165
|
+
cleanups.push(() => observer.disconnect());
|
|
166
|
+
} else if (mode === "idle") {
|
|
167
|
+
if (typeof requestIdleCallback !== "undefined") {
|
|
168
|
+
const handle = requestIdleCallback(
|
|
169
|
+
() => {
|
|
170
|
+
if (!disposed) void hydrate();
|
|
171
|
+
},
|
|
172
|
+
{ timeout: 2000 },
|
|
173
|
+
);
|
|
174
|
+
cleanups.push(() => cancelIdleCallback(handle));
|
|
175
|
+
} else {
|
|
176
|
+
const handle = setTimeout(() => {
|
|
177
|
+
if (!disposed) void hydrate();
|
|
178
|
+
}, 1);
|
|
179
|
+
cleanups.push(() => clearTimeout(handle));
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
void hydrate();
|
|
183
|
+
}
|
|
119
184
|
}
|
|
185
|
+
return () => {
|
|
186
|
+
disposed = true;
|
|
187
|
+
for (const cleanup of cleanups) cleanup();
|
|
188
|
+
};
|
|
120
189
|
}
|
package/src/renderer.tsx
CHANGED
|
@@ -78,7 +78,9 @@ function buildIslandHeadAndTail(
|
|
|
78
78
|
const modules: Record<string, string> = {};
|
|
79
79
|
|
|
80
80
|
for (const entry of registry.entries.values()) {
|
|
81
|
-
|
|
81
|
+
if (entry.hydrate !== "idle" && entry.hydrate !== "visible") {
|
|
82
|
+
preloads.add(escapeHtml(entry.specifier));
|
|
83
|
+
}
|
|
82
84
|
modules[entry.id] = entry.specifier;
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -106,6 +108,10 @@ function buildIslandHeadAndTail(
|
|
|
106
108
|
return { head: headParts.join(""), tail: tailParts.join("") };
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/** Document shell matching crepuscularity CLI `assets/web/index.html`. */
|
|
112
|
+
const DOCUMENT_HEAD =
|
|
113
|
+
'<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>html{scroll-behavior:smooth}html,body{margin:0}body{min-height:100vh}</style>';
|
|
114
|
+
|
|
109
115
|
async function renderPage(context: RenderContext): Promise<string> {
|
|
110
116
|
const registry: IslandRegistry = { entries: new Map() };
|
|
111
117
|
const body = await buildBody(context, registry);
|
|
@@ -116,7 +122,7 @@ async function renderPage(context: RenderContext): Promise<string> {
|
|
|
116
122
|
context.route.clientEntries,
|
|
117
123
|
isIslandMode,
|
|
118
124
|
);
|
|
119
|
-
return `<!DOCTYPE html><html><head>${head}</head><body>${bodyMarkup}${tail}</body></html>`;
|
|
125
|
+
return `<!DOCTYPE html><html lang="en"><head>${DOCUMENT_HEAD}${head}</head><body>${bodyMarkup}${tail}</body></html>`;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
function renderSpaShell(context: RenderContext): string {
|
|
@@ -127,7 +133,7 @@ function renderSpaShell(context: RenderContext): string {
|
|
|
127
133
|
head.push(`<link rel="modulepreload" href="${escapeHtml(entry)}">`);
|
|
128
134
|
tail.push(`<script type="module" src="${escapeHtml(entry)}"></script>`);
|
|
129
135
|
}
|
|
130
|
-
return `<!DOCTYPE html><html><head>${head.join("")}</head><body><div id="moonshine-app" data-moonshine-app="${escapeHtml(context.route.id)}"></div>${tail.join("")}</body></html>`;
|
|
136
|
+
return `<!DOCTYPE html><html lang="en"><head>${DOCUMENT_HEAD}${head.join("")}</head><body><div id="moonshine-app" data-moonshine-app="${escapeHtml(context.route.id)}"></div>${tail.join("")}</body></html>`;
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
export const reactRenderer: Renderer = {
|