@tschk/moonshine-react 0.4.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-react",
3
- "version": "0.4.0",
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.3.6",
20
- "@tschk/moonshine-framework": "^0.3.6",
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 id = getIslandId(loader);
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("div", { "data-moonshine-island": id }, children),
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 propsEl = document.querySelector(
104
- `script[data-moonshine-island-props="${id}"]`,
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
- import(specifier).then(async (mod) => {
110
- const { hydrateRoot } = await import("react-dom/client");
111
- const { createElement } = await import("react");
112
- const Component = ((mod as { default?: ComponentType<unknown> })
113
- .default ?? mod) as FunctionComponent<unknown>;
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
- preloads.add(escapeHtml(entry.specifier));
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 = {