@relicloops/cathode 2.0.0 → 2.1.0-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.
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * esbuild plugin that automatically wraps exported JSX components with
4
+ * cathode's HMR proxy at build time.
5
+ *
6
+ * Only activate this plugin in watch / dev mode:
7
+ *
8
+ * plugins: [
9
+ * ...(watchMode ? [cathodeHMRPlugin()] : []),
10
+ * deployPlugin,
11
+ * ]
12
+ *
13
+ * The plugin detects top-level exports whose name starts with an uppercase
14
+ * letter (PascalCase convention for components) and rewrites:
15
+ *
16
+ * export const Foo = ( props ) => { ... };
17
+ * export function Bar( props ) { ... }
18
+ *
19
+ * into:
20
+ *
21
+ * import { hmr as __cathode_hmr } from '@relicloops/cathode/hmr';
22
+ * const _Foo = ( props ) => { ... };
23
+ * function _Bar( props ) { ... }
24
+ * export const Foo = __cathode_hmr.wrap( _Foo, import.meta.url, 'Foo' );
25
+ * export const Bar = __cathode_hmr.wrap( _Bar, import.meta.url, 'Bar' );
26
+ *
27
+ * Files that already contain '__cathode_hmr' or 'hmr.wrap' are left
28
+ * untouched so that explicit wrapping is never doubled.
29
+ */
30
+ export function cathodeHMRPlugin(options = {}) {
31
+ const filter = options.filter ?? /\.tsx$/;
32
+ return {
33
+ name: 'cathode-hmr',
34
+ setup(build) {
35
+ build.onLoad({ filter }, ({ path }) => {
36
+ const source = readFileSync(path, 'utf8');
37
+ // Skip files that already opt in to explicit HMR wrapping.
38
+ if (source.includes('__cathode_hmr') || source.includes('hmr.wrap')) {
39
+ return undefined;
40
+ }
41
+ const names = [];
42
+ // Match top-level: export const PascalName
43
+ const constRe = /^export\s+const\s+([A-Z]\w*)\b/gm;
44
+ // Match top-level: export function PascalName
45
+ const fnRe = /^export\s+function\s+([A-Z]\w*)\b/gm;
46
+ let m;
47
+ while ((m = constRe.exec(source)) !== null) {
48
+ names.push(m[1]);
49
+ }
50
+ while ((m = fnRe.exec(source)) !== null) {
51
+ names.push(m[1]);
52
+ }
53
+ if (names.length === 0) {
54
+ return undefined;
55
+ }
56
+ // Rename each exported symbol to a private name by stripping
57
+ // the 'export' keyword and prefixing the identifier with '_'.
58
+ let transformed = source;
59
+ for (const name of names) {
60
+ transformed = transformed
61
+ .replace(new RegExp(`^export\\s+const\\s+${name}\\b`, 'gm'), `const _${name}`)
62
+ .replace(new RegExp(`^export\\s+function\\s+${name}\\b`, 'gm'), `function _${name}`);
63
+ }
64
+ const hmrImport = `import { hmr as __cathode_hmr } from '@relicloops/cathode/hmr';\n`;
65
+ const wrapLines = names
66
+ .map(name => `export const ${name} = __cathode_hmr.wrap( _${name}, import.meta.url, '${name}' );`)
67
+ .join('\n');
68
+ return {
69
+ contents: `${hmrImport}${transformed}\n${wrapLines}\n`,
70
+ loader: 'tsx',
71
+ };
72
+ });
73
+ },
74
+ };
75
+ }
package/hmr.js ADDED
@@ -0,0 +1,71 @@
1
+ import { applyUpdate, enableDevMode, isDevMode, refreshModule, registerRoot, useCallback, useEffect, useMemo, useRef, useState, wrapComponent, } from './lib/hmr-runtime.js';
2
+ export const hmr = {
3
+ wrap: wrapComponent,
4
+ registerRoot,
5
+ applyUpdate,
6
+ refreshModule,
7
+ };
8
+ export { applyUpdate, enableDevMode, refreshModule, registerRoot, useCallback, useEffect, useMemo, useRef, useState, wrapComponent as wrap, };
9
+ export function connectHMR(options = {}) {
10
+ const isDev = import.meta?.env?.DEV;
11
+ if (!isDev && !isDevMode()) {
12
+ console.warn('[hmr] disabled outside dev mode');
13
+ return () => { };
14
+ }
15
+ const endpoint = options.endpoint ?? '/__neon/hmr';
16
+ const source = new EventSource(endpoint);
17
+ console.trace('[hmr] connecting', endpoint);
18
+ source.onopen = () => {
19
+ console.trace('[hmr] connected', endpoint);
20
+ };
21
+ source.onerror = (err) => {
22
+ console.trace('[hmr] error', err);
23
+ };
24
+ source.onmessage = (msg) => {
25
+ console.trace('[hmr] message', msg.data);
26
+ let data;
27
+ try {
28
+ data = JSON.parse(msg.data);
29
+ }
30
+ catch {
31
+ console.trace('[hmr] parse error', msg.data);
32
+ return;
33
+ }
34
+ if (data.type !== 'hmr') {
35
+ console.trace('[hmr] ignored event type', data.type);
36
+ return;
37
+ }
38
+ console.trace('[hmr] update received', data);
39
+ options.onBeforeReload?.(data);
40
+ void handleHotUpdate(data, options);
41
+ };
42
+ return () => {
43
+ console.trace('[hmr] closing', endpoint);
44
+ source.close();
45
+ };
46
+ }
47
+ async function handleHotUpdate(data, options) {
48
+ const moduleUrl = normalizeModuleUrl(data.path, options.pathTransform);
49
+ // Skip directories and non-JS paths (CSS, HTML, media, empty string, etc.).
50
+ // NeonSignal fires a directory-level event in addition to file-level events;
51
+ // importing a directory URL returns HTML, not a JS module.
52
+ if (!moduleUrl.endsWith('.js')) {
53
+ return;
54
+ }
55
+ try {
56
+ const mod = await import(`${moduleUrl}?t=${Date.now()}`);
57
+ options.onHotUpdate?.(data, moduleUrl, mod);
58
+ }
59
+ catch (error) {
60
+ options.onHotError?.(data, error);
61
+ }
62
+ }
63
+ function normalizeModuleUrl(path, transform) {
64
+ const transformed = transform ? transform(path) : path;
65
+ try {
66
+ return new URL(transformed, window.location.origin).pathname;
67
+ }
68
+ catch {
69
+ return transformed;
70
+ }
71
+ }
@@ -0,0 +1,415 @@
1
+ const rootMap = new WeakMap();
2
+ const moduleRegistry = new Map();
3
+ const scheduledRoots = new Set();
4
+ let devMode = false;
5
+ let currentInstance = null;
6
+ let renderNode = null;
7
+ let renderRoot = null;
8
+ export function enableDevMode() {
9
+ devMode = true;
10
+ }
11
+ export function isDevMode() {
12
+ return devMode;
13
+ }
14
+ export function setDevRenderers(nodeRenderer, rootRenderer) {
15
+ renderNode = nodeRenderer;
16
+ renderRoot = rootRenderer;
17
+ }
18
+ export function prepareRootContext(parent, node) {
19
+ const existing = rootMap.get(parent);
20
+ const root = existing ?? {
21
+ parent,
22
+ instances: [],
23
+ cursor: 0,
24
+ effects: [],
25
+ lastNode: node,
26
+ };
27
+ root.cursor = 0;
28
+ root.effects = [];
29
+ root.lastNode = node;
30
+ rootMap.set(parent, root);
31
+ return root;
32
+ }
33
+ export function finishRootRender(root) {
34
+ trimInstances(root.instances, root.cursor);
35
+ flushEffects(root);
36
+ }
37
+ export function createOrReuseInstance(root, parentInstance, component, props) {
38
+ const list = parentInstance ? parentInstance.childInstances : root.instances;
39
+ const index = parentInstance ? parentInstance.childCursor : root.cursor;
40
+ let instance = list[index];
41
+ if (!instance) {
42
+ instance = {
43
+ component,
44
+ props,
45
+ hooks: [],
46
+ hookIndex: 0,
47
+ childInstances: [],
48
+ childCursor: 0,
49
+ root,
50
+ domStart: null,
51
+ domEnd: null,
52
+ };
53
+ list[index] = instance;
54
+ }
55
+ else if (instance.component !== component) {
56
+ disposeInstance(instance);
57
+ instance.component = component;
58
+ instance.props = props;
59
+ instance.hooks = [];
60
+ instance.childInstances = [];
61
+ instance.childCursor = 0;
62
+ instance.root = root;
63
+ }
64
+ instance.component = component;
65
+ instance.props = props;
66
+ instance.hookIndex = 0;
67
+ instance.childCursor = 0;
68
+ instance.root = root;
69
+ if (parentInstance) {
70
+ parentInstance.childCursor++;
71
+ }
72
+ else {
73
+ root.cursor++;
74
+ }
75
+ registerInstanceIfNeeded(instance, component);
76
+ return instance;
77
+ }
78
+ export function beginInstanceRender(instance) {
79
+ currentInstance = instance;
80
+ instance.hookIndex = 0;
81
+ }
82
+ export function endInstanceRender() {
83
+ currentInstance = null;
84
+ }
85
+ export function finalizeInstance(instance) {
86
+ trimInstances(instance.childInstances, instance.childCursor);
87
+ }
88
+ export function makeInstanceFragment(instance, content) {
89
+ const fragment = document.createDocumentFragment();
90
+ const start = document.createComment('cathode:hmr:start');
91
+ const end = document.createComment('cathode:hmr:end');
92
+ instance.domStart = start;
93
+ instance.domEnd = end;
94
+ fragment.appendChild(start);
95
+ fragment.appendChild(content);
96
+ fragment.appendChild(end);
97
+ return fragment;
98
+ }
99
+ export function useState(initial) {
100
+ const instance = requireInstance();
101
+ const index = instance.hookIndex++;
102
+ let hook = instance.hooks[index];
103
+ if (!hook) {
104
+ const state = typeof initial === 'function' ? initial() : initial;
105
+ hook = {
106
+ kind: 'state',
107
+ state,
108
+ setState: (value) => {
109
+ const next = typeof value === 'function'
110
+ ? value(hook.state)
111
+ : value;
112
+ if (Object.is(next, hook.state)) {
113
+ return;
114
+ }
115
+ hook.state = next;
116
+ scheduleRootRender(instance.root);
117
+ },
118
+ };
119
+ instance.hooks[index] = hook;
120
+ }
121
+ return [hook.state, hook.setState];
122
+ }
123
+ export function useEffect(create, deps) {
124
+ const instance = requireInstance();
125
+ const index = instance.hookIndex++;
126
+ let hook = instance.hooks[index];
127
+ if (!hook) {
128
+ hook = {
129
+ kind: 'effect',
130
+ deps,
131
+ };
132
+ instance.hooks[index] = hook;
133
+ instance.root.effects.push({ instance, index, create, deps });
134
+ return;
135
+ }
136
+ if (!depsEqual(hook.deps, deps)) {
137
+ hook.deps = deps;
138
+ instance.root.effects.push({ instance, index, create, deps });
139
+ }
140
+ }
141
+ export function useMemo(factory, deps) {
142
+ const instance = requireInstance();
143
+ const index = instance.hookIndex++;
144
+ let hook = instance.hooks[index];
145
+ if (!hook) {
146
+ const value = factory();
147
+ hook = {
148
+ kind: 'memo',
149
+ deps,
150
+ value,
151
+ };
152
+ instance.hooks[index] = hook;
153
+ return value;
154
+ }
155
+ if (depsEqual(hook.deps, deps)) {
156
+ return hook.value;
157
+ }
158
+ const value = factory();
159
+ hook.deps = deps;
160
+ hook.value = value;
161
+ return value;
162
+ }
163
+ export function useRef(initial) {
164
+ const instance = requireInstance();
165
+ const index = instance.hookIndex++;
166
+ let hook = instance.hooks[index];
167
+ if (!hook) {
168
+ hook = {
169
+ kind: 'ref',
170
+ ref: { current: initial },
171
+ };
172
+ instance.hooks[index] = hook;
173
+ }
174
+ return hook.ref;
175
+ }
176
+ export function useCallback(fn, deps) {
177
+ const instance = requireInstance();
178
+ const index = instance.hookIndex++;
179
+ let hook = instance.hooks[index];
180
+ if (!hook) {
181
+ hook = {
182
+ kind: 'callback',
183
+ deps,
184
+ value: fn,
185
+ };
186
+ instance.hooks[index] = hook;
187
+ return fn;
188
+ }
189
+ if (depsEqual(hook.deps, deps)) {
190
+ return hook.value;
191
+ }
192
+ hook.deps = deps;
193
+ hook.value = fn;
194
+ return fn;
195
+ }
196
+ // Strip the esbuild content hash from chunk filenames so that registry keys
197
+ // remain stable across rebuilds. Works for both full URLs and path strings.
198
+ // 'https://host/Index-DFFMCZIQ.js' → '/Index.js'
199
+ // '/Index-DFFMCZIQ.js?t=1234' → '/Index.js'
200
+ // '/app.js' → '/app.js' (no hash — unchanged)
201
+ function normalizeRegistryKey(url) {
202
+ let pathname;
203
+ try {
204
+ pathname = new URL(url, 'http://x').pathname;
205
+ }
206
+ catch {
207
+ pathname = url.split('?')[0];
208
+ }
209
+ // esbuild hashes are exactly 8 uppercase-alphanumeric chars before .js
210
+ return pathname.replace(/-[A-Za-z0-9]{8}(?=\.js$)/, '');
211
+ }
212
+ export function wrapComponent(component, moduleUrl, exportName) {
213
+ const key = normalizeRegistryKey(moduleUrl);
214
+ const meta = { moduleUrl: key, exportName };
215
+ // Reuse the existing proxy across HMR reloads so that instances holding a
216
+ // reference to the proxy are never disposed due to a reference identity change.
217
+ // The proxy delegates to __current, which we update in place on each reload.
218
+ const record = getModuleRecord(key);
219
+ const existing = record.exports.get(exportName);
220
+ if (existing) {
221
+ console.trace('[hmr:registry] wrapComponent:update', { key, exportName });
222
+ existing.__current = component;
223
+ return existing;
224
+ }
225
+ console.trace('[hmr:registry] wrapComponent:create', { key, exportName });
226
+ const proxy = ((props) => proxy.__current(props));
227
+ proxy.__current = component;
228
+ proxy.__cathode_hmr = meta;
229
+ registerExport(meta, proxy);
230
+ return proxy;
231
+ }
232
+ export function registerRoot(moduleUrl, parent, getNode) {
233
+ const key = normalizeRegistryKey(moduleUrl);
234
+ console.trace('[hmr:registry] registerRoot', { raw: moduleUrl, key });
235
+ const record = getModuleRecord(key);
236
+ record.roots.add({ parent, getNode });
237
+ }
238
+ export function applyUpdate(moduleUrl, mod) {
239
+ const key = normalizeRegistryKey(moduleUrl);
240
+ const record = moduleRegistry.get(key);
241
+ console.trace('[hmr:registry] applyUpdate', {
242
+ raw: moduleUrl,
243
+ key,
244
+ found: !!record,
245
+ exports: record ? [...record.exports.keys()] : [],
246
+ instances: record ? record.instances.size : 0,
247
+ incoming: Object.keys(mod),
248
+ });
249
+ // Proxy implementations are already updated by wrapComponent during module
250
+ // re-import, so there is nothing left to do here.
251
+ }
252
+ export function refreshModule(moduleUrl) {
253
+ const key = normalizeRegistryKey(moduleUrl);
254
+ const record = moduleRegistry.get(key);
255
+ console.trace('[hmr:registry] refreshModule', {
256
+ raw: moduleUrl,
257
+ key,
258
+ found: !!record,
259
+ instances: record ? record.instances.size : 0,
260
+ roots: record ? record.roots.size : 0,
261
+ });
262
+ if (!record) {
263
+ return;
264
+ }
265
+ if (record.instances.size > 0) {
266
+ record.instances.forEach((instance) => {
267
+ rerenderInstance(instance);
268
+ });
269
+ return;
270
+ }
271
+ record.roots.forEach((root) => {
272
+ rerenderRoot(root);
273
+ });
274
+ }
275
+ export function registerInstanceIfNeeded(instance, component) {
276
+ const meta = component.__cathode_hmr;
277
+ if (!meta) {
278
+ if (instance.meta) {
279
+ unregisterInstance(instance, instance.meta);
280
+ instance.meta = undefined;
281
+ }
282
+ return;
283
+ }
284
+ if (instance.meta?.moduleUrl !== meta.moduleUrl || instance.meta?.exportName !== meta.exportName) {
285
+ if (instance.meta) {
286
+ unregisterInstance(instance, instance.meta);
287
+ }
288
+ instance.meta = meta;
289
+ }
290
+ const record = getModuleRecord(meta.moduleUrl);
291
+ record.instances.add(instance);
292
+ }
293
+ function requireInstance() {
294
+ if (!currentInstance) {
295
+ throw new Error('Cathode hooks can only be used inside components.');
296
+ }
297
+ return currentInstance;
298
+ }
299
+ function depsEqual(prev, next) {
300
+ if (prev === next) {
301
+ return true;
302
+ }
303
+ if (!prev || !next) {
304
+ return false;
305
+ }
306
+ if (prev.length !== next.length) {
307
+ return false;
308
+ }
309
+ for (let i = 0; i < prev.length; i++) {
310
+ if (!Object.is(prev[i], next[i])) {
311
+ return false;
312
+ }
313
+ }
314
+ return true;
315
+ }
316
+ function flushEffects(root) {
317
+ root.effects.forEach((record) => {
318
+ const hook = record.instance.hooks[record.index];
319
+ if (hook?.cleanup) {
320
+ hook.cleanup();
321
+ }
322
+ const cleanup = record.create();
323
+ if (hook) {
324
+ hook.cleanup = typeof cleanup === 'function' ? cleanup : undefined;
325
+ }
326
+ });
327
+ root.effects = [];
328
+ }
329
+ function scheduleRootRender(root) {
330
+ if (!renderRoot) {
331
+ return;
332
+ }
333
+ if (scheduledRoots.has(root)) {
334
+ return;
335
+ }
336
+ scheduledRoots.add(root);
337
+ queueMicrotask(() => {
338
+ scheduledRoots.delete(root);
339
+ renderRoot?.(root);
340
+ });
341
+ }
342
+ function rerenderInstance(instance) {
343
+ if (!renderNode || !instance.domStart || !instance.domEnd) {
344
+ return;
345
+ }
346
+ beginInstanceRender(instance);
347
+ instance.childCursor = 0;
348
+ const nextNode = instance.component(instance.props);
349
+ endInstanceRender();
350
+ const content = renderNode(nextNode, instance.root, instance);
351
+ finalizeInstance(instance);
352
+ replaceBetween(instance.domStart, instance.domEnd, content);
353
+ flushEffects(instance.root);
354
+ }
355
+ function rerenderRoot(rootRef) {
356
+ if (!renderRoot) {
357
+ return;
358
+ }
359
+ const root = prepareRootContext(rootRef.parent, rootRef.getNode());
360
+ renderRoot(root);
361
+ }
362
+ function replaceBetween(start, end, content) {
363
+ const parent = start.parentNode;
364
+ if (!parent) {
365
+ return;
366
+ }
367
+ let cursor = start.nextSibling;
368
+ while (cursor && cursor !== end) {
369
+ const next = cursor.nextSibling;
370
+ parent.removeChild(cursor);
371
+ cursor = next;
372
+ }
373
+ parent.insertBefore(content, end);
374
+ }
375
+ function registerExport(meta, component) {
376
+ const record = getModuleRecord(meta.moduleUrl);
377
+ record.exports.set(meta.exportName, component);
378
+ }
379
+ function getModuleRecord(moduleUrl) {
380
+ let record = moduleRegistry.get(moduleUrl);
381
+ if (!record) {
382
+ record = {
383
+ exports: new Map(),
384
+ instances: new Set(),
385
+ roots: new Set(),
386
+ };
387
+ moduleRegistry.set(moduleUrl, record);
388
+ }
389
+ return record;
390
+ }
391
+ function unregisterInstance(instance, meta) {
392
+ const record = moduleRegistry.get(meta.moduleUrl);
393
+ if (!record) {
394
+ return;
395
+ }
396
+ record.instances.delete(instance);
397
+ }
398
+ function disposeInstance(instance) {
399
+ if (instance.meta) {
400
+ unregisterInstance(instance, instance.meta);
401
+ }
402
+ instance.hooks.forEach((hook) => {
403
+ if (hook.kind === 'effect' && hook.cleanup) {
404
+ hook.cleanup();
405
+ }
406
+ });
407
+ instance.childInstances.forEach(disposeInstance);
408
+ }
409
+ function trimInstances(list, cursor) {
410
+ if (list.length <= cursor) {
411
+ return;
412
+ }
413
+ const removed = list.splice(cursor);
414
+ removed.forEach(disposeInstance);
415
+ }
package/lib/lazy.js CHANGED
@@ -51,7 +51,7 @@ function createLazyComponent(loader, autoLoad) {
51
51
  }
52
52
  // console.trace( '[LazyWrapper.awake] Starting loader()' );
53
53
  LazyWrapper.__promise = loader()
54
- .then(mod => {
54
+ .then((mod) => {
55
55
  let component = null;
56
56
  if (mod && typeof mod === 'object' && 'default' in mod && mod.default) {
57
57
  component = mod.default;
@@ -60,7 +60,7 @@ function createLazyComponent(loader, autoLoad) {
60
60
  component = mod;
61
61
  }
62
62
  else if (mod && typeof mod === 'object') {
63
- const candidates = Object.values(mod).filter((value) => typeof value === 'function');
63
+ const candidates = Object.values(mod).filter(value => typeof value === 'function');
64
64
  if (candidates.length === 1) {
65
65
  component = candidates[0];
66
66
  }
@@ -72,7 +72,7 @@ function createLazyComponent(loader, autoLoad) {
72
72
  LazyWrapper.__component = component;
73
73
  LazyWrapper.__status = 'resolved';
74
74
  })
75
- .catch(err => {
75
+ .catch((err) => {
76
76
  LazyWrapper.__error = err;
77
77
  LazyWrapper.__status = 'rejected';
78
78
  });
package/lib/runtime.js CHANGED
@@ -1,13 +1,21 @@
1
+ import { beginInstanceRender, createOrReuseInstance, endInstanceRender, finalizeInstance, finishRootRender, isDevMode, makeInstanceFragment, prepareRootContext, setDevRenderers, } from './hmr-runtime.js';
1
2
  import { findLazyPending } from './suspense.js';
2
3
  export function h(type, props, ...children) {
3
4
  return { type, props: props || {}, children };
4
5
  }
5
6
  export const Fragment = (props) => Array.isArray(props.children) ? props.children : [props.children];
6
7
  export function render(node, parent) {
8
+ if (isDevMode()) {
9
+ const root = prepareRootContext(parent, node);
10
+ parent.innerHTML = '';
11
+ parent.appendChild(toDOM(node, root, null));
12
+ finishRootRender(root);
13
+ return;
14
+ }
7
15
  parent.innerHTML = '';
8
16
  parent.appendChild(toDOM(node));
9
17
  }
10
- function toDOM(node) {
18
+ function toDOM(node, root, parentInstance) {
11
19
  if (node === null || node === undefined) {
12
20
  return document.createTextNode('');
13
21
  }
@@ -16,7 +24,7 @@ function toDOM(node) {
16
24
  }
17
25
  if (Array.isArray(node)) {
18
26
  const frag = document.createDocumentFragment();
19
- node.forEach(child => frag.appendChild(toDOM(child)));
27
+ node.forEach(child => frag.appendChild(toDOM(child, root, parentInstance ?? null)));
20
28
  return frag;
21
29
  }
22
30
  // Debug: log VNode type
@@ -28,13 +36,13 @@ function toDOM(node) {
28
36
  const { fallback, children } = node.props;
29
37
  const pending = findLazyPending(children);
30
38
  if (pending.length === 0) {
31
- return toDOM(children);
39
+ return toDOM(children, root, parentInstance ?? null);
32
40
  }
33
41
  const container = document.createElement('div');
34
42
  container.setAttribute('data-cathode-suspense', 'true');
35
43
  container.appendChild(toDOM(fallback));
36
44
  Promise.all(pending.map(lc => lc.awake())).then(() => {
37
- const content = toDOM(children);
45
+ const content = toDOM(children, root, parentInstance ?? null);
38
46
  container.innerHTML = '';
39
47
  container.appendChild(content);
40
48
  });
@@ -47,7 +55,7 @@ function toDOM(node) {
47
55
  placeholder.setAttribute('data-cathode-lazy', 'pending');
48
56
  wrapper.awake().then(() => {
49
57
  if (wrapper.__status === 'resolved') {
50
- const resolved = toDOM(h(wrapper.__component, componentProps));
58
+ const resolved = toDOM(h(wrapper.__component, componentProps), root, parentInstance ?? null);
51
59
  placeholder.replaceWith(resolved);
52
60
  }
53
61
  });
@@ -87,7 +95,7 @@ function toDOM(node) {
87
95
  wrapper.__promise.then(() => {
88
96
  // console.trace( '[lazy ondemand] Component loaded:', wrapper.__component?.name, 'status:', wrapper.__status, 'connected:', placeholder.isConnected );
89
97
  if (wrapper.__status === 'resolved' && placeholder.isConnected) {
90
- const resolved = toDOM(h(wrapper.__component, componentProps));
98
+ const resolved = toDOM(h(wrapper.__component, componentProps), root, parentInstance ?? null);
91
99
  // console.trace( '[lazy ondemand] Replacing placeholder with resolved component' );
92
100
  placeholder.replaceWith(resolved);
93
101
  }
@@ -119,7 +127,7 @@ function toDOM(node) {
119
127
  if (entry.isIntersecting) {
120
128
  component.awake().then(() => {
121
129
  if (component.__status === 'resolved') {
122
- const resolved = toDOM(h(component, componentProps));
130
+ const resolved = toDOM(h(component, componentProps), root, parentInstance ?? null);
123
131
  container.innerHTML = '';
124
132
  container.appendChild(resolved);
125
133
  }
@@ -147,16 +155,26 @@ function toDOM(node) {
147
155
  if (node.type === '__error_boundary__') {
148
156
  const { fallback, children } = node.props;
149
157
  try {
150
- return toDOM(children);
158
+ return toDOM(children, root, parentInstance ?? null);
151
159
  }
152
160
  catch (error) {
153
161
  if (typeof fallback === 'function') {
154
- return toDOM(fallback(error));
162
+ return toDOM(fallback(error), root, parentInstance ?? null);
155
163
  }
156
- return toDOM(fallback);
164
+ return toDOM(fallback, root, parentInstance ?? null);
157
165
  }
158
166
  }
159
167
  if (typeof node.type === 'function') {
168
+ if (isDevMode() && root) {
169
+ const props = { ...node.props, children: node.children };
170
+ const instance = createOrReuseInstance(root, parentInstance ?? null, node.type, props);
171
+ beginInstanceRender(instance);
172
+ const nextNode = instance.component(props);
173
+ endInstanceRender();
174
+ const content = toDOM(nextNode, root, instance);
175
+ finalizeInstance(instance);
176
+ return makeInstanceFragment(instance, content);
177
+ }
160
178
  return toDOM(node.type({ ...node.props, children: node.children }));
161
179
  }
162
180
  const el = document.createElement(node.type);
@@ -176,7 +194,7 @@ function toDOM(node) {
176
194
  });
177
195
  if (node.children) {
178
196
  node.children.forEach((child) => {
179
- el.appendChild(toDOM(child));
197
+ el.appendChild(toDOM(child, root, parentInstance ?? null));
180
198
  });
181
199
  }
182
200
  return el;
@@ -186,3 +204,8 @@ if (typeof globalThis !== 'undefined') {
186
204
  globalThis.h = h;
187
205
  globalThis.Fragment = Fragment;
188
206
  }
207
+ setDevRenderers((node, root, parentInstance) => toDOM(node, root, parentInstance), (root) => {
208
+ root.parent.innerHTML = '';
209
+ root.parent.appendChild(toDOM(root.lastNode, root, null));
210
+ finishRootRender(root);
211
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relicloops/cathode",
3
- "version": "2.0.0",
3
+ "version": "2.1.0-000",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lightweight JSX runtime.",
6
6
  "type": "module",
@@ -18,12 +18,22 @@
18
18
  "./jsx-dev-runtime": {
19
19
  "types": "./types/jsx-dev-runtime.d.ts",
20
20
  "default": "./jsx-dev-runtime.js"
21
+ },
22
+ "./hmr": {
23
+ "types": "./types/hmr.d.ts",
24
+ "default": "./hmr.js"
25
+ },
26
+ "./esbuild-plugin": {
27
+ "types": "./types/esbuild-plugin.d.ts",
28
+ "default": "./esbuild-plugin.js"
21
29
  }
22
30
  },
23
31
  "files": [
24
32
  "index.js",
25
33
  "jsx-runtime.js",
26
34
  "jsx-dev-runtime.js",
35
+ "hmr.js",
36
+ "esbuild-plugin.js",
27
37
  "lib/**/*.js",
28
38
  "types/**/*.d.ts",
29
39
  "README.md",
@@ -0,0 +1,50 @@
1
+ type Build = {
2
+ onLoad: (options: {
3
+ filter: RegExp;
4
+ }, callback: (args: {
5
+ path: string;
6
+ }) => {
7
+ contents: string;
8
+ loader: string;
9
+ } | undefined) => void;
10
+ };
11
+ export type CathodeHMRPluginOptions = {
12
+ /**
13
+ * Regex that controls which files are transformed.
14
+ * Default: /\.tsx$/ — all TSX files in the bundle.
15
+ */
16
+ filter?: RegExp;
17
+ };
18
+ /**
19
+ * esbuild plugin that automatically wraps exported JSX components with
20
+ * cathode's HMR proxy at build time.
21
+ *
22
+ * Only activate this plugin in watch / dev mode:
23
+ *
24
+ * plugins: [
25
+ * ...(watchMode ? [cathodeHMRPlugin()] : []),
26
+ * deployPlugin,
27
+ * ]
28
+ *
29
+ * The plugin detects top-level exports whose name starts with an uppercase
30
+ * letter (PascalCase convention for components) and rewrites:
31
+ *
32
+ * export const Foo = ( props ) => { ... };
33
+ * export function Bar( props ) { ... }
34
+ *
35
+ * into:
36
+ *
37
+ * import { hmr as __cathode_hmr } from '@relicloops/cathode/hmr';
38
+ * const _Foo = ( props ) => { ... };
39
+ * function _Bar( props ) { ... }
40
+ * export const Foo = __cathode_hmr.wrap( _Foo, import.meta.url, 'Foo' );
41
+ * export const Bar = __cathode_hmr.wrap( _Bar, import.meta.url, 'Bar' );
42
+ *
43
+ * Files that already contain '__cathode_hmr' or 'hmr.wrap' are left
44
+ * untouched so that explicit wrapping is never doubled.
45
+ */
46
+ export declare function cathodeHMRPlugin(options?: CathodeHMRPluginOptions): {
47
+ name: string;
48
+ setup: (build: Build) => void;
49
+ };
50
+ export {};
package/types/hmr.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { applyUpdate, enableDevMode, refreshModule, registerRoot, useCallback, useEffect, useMemo, useRef, useState, wrapComponent } from './lib/hmr-runtime.js';
2
+ export type HMREvent = {
3
+ type: 'hmr';
4
+ path: string;
5
+ event: 'created' | 'modified' | 'deleted' | 'renamed';
6
+ };
7
+ export type HMROptions = {
8
+ endpoint?: string;
9
+ pathTransform?: (path: string) => string;
10
+ onBeforeReload?: (event: HMREvent) => void;
11
+ onHotUpdate?: (event: HMREvent, moduleUrl: string, mod: Record<string, any>) => void;
12
+ onHotError?: (event: HMREvent, error: unknown) => void;
13
+ };
14
+ export declare const hmr: {
15
+ wrap: typeof wrapComponent;
16
+ registerRoot: typeof registerRoot;
17
+ applyUpdate: typeof applyUpdate;
18
+ refreshModule: typeof refreshModule;
19
+ };
20
+ export { applyUpdate, enableDevMode, refreshModule, registerRoot, useCallback, useEffect, useMemo, useRef, useState, wrapComponent as wrap, };
21
+ export declare function connectHMR(options?: HMROptions): () => void;
@@ -0,0 +1,82 @@
1
+ export type HMRMeta = {
2
+ moduleUrl: string;
3
+ exportName: string;
4
+ };
5
+ export type ComponentFn = (props: any) => any;
6
+ export type ComponentInstance = {
7
+ component: ComponentFn;
8
+ props: any;
9
+ hooks: HookEntry[];
10
+ hookIndex: number;
11
+ childInstances: ComponentInstance[];
12
+ childCursor: number;
13
+ root: RootContext;
14
+ domStart: Comment | null;
15
+ domEnd: Comment | null;
16
+ meta?: HMRMeta;
17
+ };
18
+ export type RootContext = {
19
+ parent: HTMLElement;
20
+ instances: ComponentInstance[];
21
+ cursor: number;
22
+ effects: EffectRecord[];
23
+ lastNode: any;
24
+ };
25
+ type EffectRecord = {
26
+ instance: ComponentInstance;
27
+ index: number;
28
+ create: () => void | (() => void);
29
+ deps?: any[];
30
+ };
31
+ type HookEntry = StateHook | EffectHook | MemoHook | RefHook | CallbackHook;
32
+ type StateHook = {
33
+ kind: 'state';
34
+ state: any;
35
+ setState: (value: any) => void;
36
+ };
37
+ type EffectHook = {
38
+ kind: 'effect';
39
+ deps?: any[];
40
+ cleanup?: () => void;
41
+ };
42
+ type MemoHook = {
43
+ kind: 'memo';
44
+ deps?: any[];
45
+ value: any;
46
+ };
47
+ type RefHook = {
48
+ kind: 'ref';
49
+ ref: {
50
+ current: any;
51
+ };
52
+ };
53
+ type CallbackHook = {
54
+ kind: 'callback';
55
+ deps?: any[];
56
+ value: any;
57
+ };
58
+ type RootRender = (root: RootContext) => void;
59
+ type NodeRender = (node: any, root: RootContext, parentInstance: ComponentInstance | null) => Node;
60
+ export declare function enableDevMode(): void;
61
+ export declare function isDevMode(): boolean;
62
+ export declare function setDevRenderers(nodeRenderer: NodeRender, rootRenderer: RootRender): void;
63
+ export declare function prepareRootContext(parent: HTMLElement, node: any): RootContext;
64
+ export declare function finishRootRender(root: RootContext): void;
65
+ export declare function createOrReuseInstance(root: RootContext, parentInstance: ComponentInstance | null, component: ComponentFn, props: any): ComponentInstance;
66
+ export declare function beginInstanceRender(instance: ComponentInstance): void;
67
+ export declare function endInstanceRender(): void;
68
+ export declare function finalizeInstance(instance: ComponentInstance): void;
69
+ export declare function makeInstanceFragment(instance: ComponentInstance, content: Node): DocumentFragment;
70
+ export declare function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T) => T)) => void];
71
+ export declare function useEffect(create: () => void | (() => void), deps?: any[]): void;
72
+ export declare function useMemo<T>(factory: () => T, deps?: any[]): T;
73
+ export declare function useRef<T>(initial: T): {
74
+ current: T;
75
+ };
76
+ export declare function useCallback<T extends (...args: any[]) => any>(fn: T, deps?: any[]): T;
77
+ export declare function wrapComponent<T extends ComponentFn>(component: T, moduleUrl: string, exportName: string): T;
78
+ export declare function registerRoot(moduleUrl: string, parent: HTMLElement, getNode: () => any): void;
79
+ export declare function applyUpdate(moduleUrl: string, mod: Record<string, any>): void;
80
+ export declare function refreshModule(moduleUrl: string): void;
81
+ export declare function registerInstanceIfNeeded(instance: ComponentInstance, component: ComponentFn): void;
82
+ export {};
@@ -1,5 +1,5 @@
1
- import type { VNode } from './runtime.js';
2
1
  import type { LazyComponent } from './lazy.js';
2
+ import type { VNode } from './runtime.js';
3
3
  export interface SuspenseProps {
4
4
  fallback: VNode | string | number;
5
5
  children?: any;