@c9up/aurora 0.1.4 → 0.1.6

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,28 @@
1
+ /**
2
+ * Browser DX helpers — navigation + a typed `localStorage` wrapper.
3
+ *
4
+ * All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
5
+ * a no-op and storage reads return `null`, so the same code runs during SSR
6
+ * without `typeof window` guards at every call site. Node-free — part of the
7
+ * client barrel.
8
+ */
9
+ /** Navigate to `url` with a full page load. No-op during SSR. */
10
+ export declare function redirect(url: string): void;
11
+ /**
12
+ * Navigate to `url`, replacing the current history entry (no back-button trap —
13
+ * use after a login/logout so "back" doesn't return to the form). No-op on SSR.
14
+ */
15
+ export declare function replace(url: string): void;
16
+ /** Reload the current page. No-op during SSR. */
17
+ export declare function reload(): void;
18
+ /**
19
+ * Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
20
+ * return `null` on the server, on a missing key, or on malformed JSON. Writes
21
+ * swallow quota / private-mode errors so a full storage never crashes the app.
22
+ */
23
+ export declare const storage: {
24
+ get<T>(key: string): T | null;
25
+ set(key: string, value: unknown): void;
26
+ remove(key: string): void;
27
+ clear(): void;
28
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Browser DX helpers — navigation + a typed `localStorage` wrapper.
3
+ *
4
+ * All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
5
+ * a no-op and storage reads return `null`, so the same code runs during SSR
6
+ * without `typeof window` guards at every call site. Node-free — part of the
7
+ * client barrel.
8
+ */
9
+ /** Navigate to `url` with a full page load. No-op during SSR. */
10
+ export function redirect(url) {
11
+ if (typeof window !== "undefined") {
12
+ window.location.href = url;
13
+ }
14
+ }
15
+ /**
16
+ * Navigate to `url`, replacing the current history entry (no back-button trap —
17
+ * use after a login/logout so "back" doesn't return to the form). No-op on SSR.
18
+ */
19
+ export function replace(url) {
20
+ if (typeof window !== "undefined") {
21
+ window.location.replace(url);
22
+ }
23
+ }
24
+ /** Reload the current page. No-op during SSR. */
25
+ export function reload() {
26
+ if (typeof window !== "undefined") {
27
+ window.location.reload();
28
+ }
29
+ }
30
+ /**
31
+ * Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
32
+ * return `null` on the server, on a missing key, or on malformed JSON. Writes
33
+ * swallow quota / private-mode errors so a full storage never crashes the app.
34
+ */
35
+ export const storage = {
36
+ get(key) {
37
+ if (typeof localStorage === "undefined")
38
+ return null;
39
+ const raw = localStorage.getItem(key);
40
+ if (raw === null)
41
+ return null;
42
+ try {
43
+ return JSON.parse(raw);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ },
49
+ set(key, value) {
50
+ if (typeof localStorage === "undefined")
51
+ return;
52
+ try {
53
+ localStorage.setItem(key, JSON.stringify(value));
54
+ }
55
+ catch {
56
+ // QuotaExceededError / Safari private mode — best-effort write.
57
+ }
58
+ },
59
+ remove(key) {
60
+ if (typeof localStorage !== "undefined") {
61
+ localStorage.removeItem(key);
62
+ }
63
+ },
64
+ clear() {
65
+ if (typeof localStorage !== "undefined") {
66
+ localStorage.clear();
67
+ }
68
+ },
69
+ };
package/dist/component.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * signals the setup function captures. The compiled template is what
19
19
  * actually moves on screen.
20
20
  */
21
+ import { setOwner } from "./reactive.js";
21
22
  const contextStack = [];
22
23
  function activeContext() {
23
24
  const ctx = contextStack[contextStack.length - 1];
@@ -41,11 +42,17 @@ export function component(setup) {
41
42
  mountHooks: [],
42
43
  };
43
44
  contextStack.push(ctx);
45
+ // Own the reactive scope of setup: effects/memos created here register
46
+ // their disposer into ctx.cleanups and tear down at unmount, instead of
47
+ // leaking their signal subscriptions. Restored after setup so the outer
48
+ // scope (or none) resumes. Save/restore handles nested component() calls.
49
+ const prevOwner = setOwner(ctx.cleanups);
44
50
  try {
45
51
  const result = setup((props ?? {}));
46
52
  return wrapWithLifecycle(result, ctx);
47
53
  }
48
54
  finally {
55
+ setOwner(prevOwner);
49
56
  contextStack.pop();
50
57
  }
51
58
  };
package/dist/hydrate.js CHANGED
@@ -307,11 +307,21 @@ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCurso
307
307
  }
308
308
  return;
309
309
  }
310
- const textNode = commentMarker.nodeType === 3 /* TEXT */
310
+ let textNode = commentMarker.nodeType === 3 /* TEXT */
311
311
  ? commentMarker
312
312
  : commentMarker.previousSibling?.nodeType === 3
313
313
  ? commentMarker.previousSibling
314
314
  : null;
315
+ if (!textNode &&
316
+ commentMarker.nodeType === 8 /* Comment */ &&
317
+ commentMarker.parentNode) {
318
+ // Empty SSR text slot: a `<!---->` placeholder holds the position
319
+ // (see ssr.ts). Materialize the reactive text node there — node
320
+ // count stays 1, so sibling slot paths remain aligned.
321
+ const fresh = (commentMarker.ownerDocument ?? document).createTextNode("");
322
+ commentMarker.parentNode.replaceChild(fresh, commentMarker);
323
+ textNode = fresh;
324
+ }
315
325
  if (!textNode)
316
326
  return;
317
327
  const dispose = effect(() => {
package/dist/index.d.ts CHANGED
@@ -1,12 +1,9 @@
1
- export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
1
+ export { redirect, reload, replace, storage } from "./browser.js";
2
2
  export { component, onMount, onUnmount } from "./component.js";
3
3
  export { html, isTemplateResult } from "./html.js";
4
4
  export { hydrate } from "./hydrate.js";
5
- export { type PageFactory, Pages, type PagesConfig, } from "./Pages.js";
6
5
  export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
7
6
  export { type Disposer, render } from "./render.js";
8
7
  export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
9
- export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
10
- export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
11
8
  export { renderToString } from "./ssr.js";
12
9
  export type { TemplateResult } from "./types.js";
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
- // ─── Inertia-shape server surface ─────────────────────────────────
2
- export { AuroraManager } from "./AuroraManager.js";
1
+ // ─── Client surface (node-free safe to bundle for the browser) ──────
2
+ //
3
+ // Server-only exports (AuroraManager, Pages, renderPage, serveAssets) that pull
4
+ // node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
5
+ // this barrel is what lets a browser bundle import the client primitives without
6
+ // the bundler dragging Node built-ins through the import graph.
7
+ export { redirect, reload, replace, storage } from "./browser.js";
3
8
  export { component, onMount, onUnmount } from "./component.js";
4
9
  export { html, isTemplateResult } from "./html.js";
5
10
  export { hydrate } from "./hydrate.js";
6
- export { Pages, } from "./Pages.js";
7
11
  export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
8
12
  export { render } from "./render.js";
9
13
  export { auroraRoute, } from "./route.js";
10
- export { renderPage, } from "./server/renderPage.js";
11
- export { serveAssets, } from "./server/serveAssets.js";
12
14
  export { renderToString } from "./ssr.js";
@@ -28,6 +28,13 @@ export interface Signal<T> {
28
28
  * sites need to import this directly.
29
29
  */
30
30
  export declare const SIGNAL_BRAND: unique symbol;
31
+ /**
32
+ * @internal Swap the ambient owner, returning the previous one so the
33
+ * caller can restore it. `component()` uses this to own the effects and
34
+ * memos a setup function creates, so they dispose at unmount instead of
35
+ * keeping their signal subscriptions alive forever.
36
+ */
37
+ export declare function setOwner(owner: Array<() => void> | undefined): Array<() => void> | undefined;
31
38
  /**
32
39
  * Create a writable signal seeded with `initial`. Reads register the
33
40
  * current observer; writes notify every observer that previously read.
package/dist/reactive.js CHANGED
@@ -23,6 +23,24 @@ const pendingNotifications = new Set();
23
23
  function activeObserver() {
24
24
  return observerStack[observerStack.length - 1];
25
25
  }
26
+ /**
27
+ * Ambient disposal owner. A non-reactive scope (e.g. a component's setup
28
+ * run) registers an array here so effects/memos created during its
29
+ * execution push their disposer into it and are torn down when the scope
30
+ * ends. `undefined` at top level — no scope, no auto-disposal.
31
+ */
32
+ let currentOwner;
33
+ /**
34
+ * @internal Swap the ambient owner, returning the previous one so the
35
+ * caller can restore it. `component()` uses this to own the effects and
36
+ * memos a setup function creates, so they dispose at unmount instead of
37
+ * keeping their signal subscriptions alive forever.
38
+ */
39
+ export function setOwner(owner) {
40
+ const prev = currentOwner;
41
+ currentOwner = owner;
42
+ return prev;
43
+ }
26
44
  /**
27
45
  * Create a writable signal seeded with `initial`. Reads register the
28
46
  * current observer; writes notify every observer that previously read.
@@ -129,7 +147,13 @@ export function effect(fn) {
129
147
  },
130
148
  };
131
149
  eff.run();
132
- return () => eff.dispose();
150
+ const dispose = () => eff.dispose();
151
+ // Register with the ambient owner (e.g. a component's setup scope) so the
152
+ // effect is torn down when that scope ends. `memo()` builds on this — its
153
+ // internal recompute effect inherits the same ownership, which is what
154
+ // stops a memo created in component setup from leaking after unmount.
155
+ currentOwner?.push(dispose);
156
+ return dispose;
133
157
  }
134
158
  /**
135
159
  * Register a cleanup callback against the currently-running effect.
package/dist/relay.js CHANGED
@@ -21,7 +21,6 @@ const STATE = {
21
21
  sse: null,
22
22
  uid: null,
23
23
  channels: new Map(),
24
- pending: [],
25
24
  };
26
25
  let CONFIG = {
27
26
  sseUrl: "/__relay/events",
@@ -60,17 +59,15 @@ const CLIENT = {
60
59
  }
61
60
  const adapted = handler;
62
61
  handlers.add(adapted);
63
- // Subscribe over POST as soon as we have a uid. If the SSE is
64
- // still mid-handshake, queue the call and flush on `connected`.
65
- const doSubscribe = () => {
62
+ // Subscribe over POST as soon as we have a uid. Before the first uid (or
63
+ // during an auto-reconnect) the channel already lives in STATE.channels
64
+ // and is (re-)subscribed by the `connected` handler — so the server,
65
+ // which assigns a fresh uid per connection, always learns every channel.
66
+ if (STATE.uid) {
66
67
  postSubscribe(channel).catch((err) => {
67
68
  console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
68
69
  });
69
- };
70
- if (STATE.uid)
71
- doSubscribe();
72
- else
73
- STATE.pending.push(doSubscribe);
70
+ }
74
71
  // Detacher — only removes the local listener. The server-side
75
72
  // subscription stays open; closing it would interrupt other
76
73
  // listeners on the same channel.
@@ -85,7 +82,6 @@ const CLIENT = {
85
82
  }
86
83
  STATE.uid = null;
87
84
  STATE.channels.clear();
88
- STATE.pending.length = 0;
89
85
  },
90
86
  };
91
87
  function open() {
@@ -95,9 +91,16 @@ function open() {
95
91
  const data = safeJson(ev.data);
96
92
  if (data && typeof data.uid === "string") {
97
93
  STATE.uid = data.uid;
98
- const queue = STATE.pending.splice(0);
99
- for (const fn of queue)
100
- fn();
94
+ // Re-apply EVERY active subscription on each (re)connect. The server
95
+ // assigns a fresh uid per connection and has no memory of prior
96
+ // subscriptions, so both the first connect AND browser auto-reconnects
97
+ // must re-POST every live channel — otherwise the client silently
98
+ // stops receiving after a reconnect.
99
+ for (const channel of STATE.channels.keys()) {
100
+ postSubscribe(channel).catch((err) => {
101
+ console.warn(`[aurora/relay] re-subscribe to ${channel} failed:`, err);
102
+ });
103
+ }
101
104
  }
102
105
  });
103
106
  sse.onmessage = (ev) => {
@@ -41,7 +41,7 @@ export async function renderPage(ctx, pages, name, props, options = {}) {
41
41
  <head>
42
42
  <meta charset="utf-8" />
43
43
  <meta name="viewport" content="width=device-width,initial-scale=1" />
44
- <script type="importmap">${JSON.stringify({ imports: importmap })}</script>
44
+ <script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
45
45
  ${options.headExtra ?? ""}
46
46
  </head>
47
47
  <body>
@@ -0,0 +1,4 @@
1
+ export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
2
+ export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
3
+ export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
4
+ export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
package/dist/server.js ADDED
@@ -0,0 +1,10 @@
1
+ // ─── Server-only surface (imports node:fs / node:path / node:url) ─────
2
+ //
3
+ // Kept OUT of the package's main barrel (`@c9up/aurora`) so a browser bundle
4
+ // importing client primitives (component/html/hydrate/render) never pulls the
5
+ // Node built-ins through the import graph. Server code imports from
6
+ // `@c9up/aurora/server`; the client `.` entry stays node-free.
7
+ export { AuroraManager } from "./AuroraManager.js";
8
+ export { Pages } from "./Pages.js";
9
+ export { renderPage, } from "./server/renderPage.js";
10
+ export { serveAssets, } from "./server/serveAssets.js";
package/dist/ssr.js CHANGED
@@ -75,8 +75,18 @@ function stringifyTemplateResult(result) {
75
75
  out += stringifyValue(value, false);
76
76
  out += `<!--${SLOT_END}-->`;
77
77
  }
78
+ else if (inAttr) {
79
+ out += stringifyValue(value, true);
80
+ }
78
81
  else {
79
- out += stringifyValue(value, inAttr);
82
+ // Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
83
+ // would emit NO node and desync the path-based hydration of the
84
+ // following sibling slots (their @input/@submit bindings break).
85
+ // Emit an empty-comment placeholder so the position is preserved
86
+ // — lit-html / Solid do the same; hydration materializes the text
87
+ // node there.
88
+ const text = stringifyValue(value, false);
89
+ out += text === "" ? "<!---->" : text;
80
90
  }
81
91
  }
82
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,10 @@
30
30
  "./hydrate": {
31
31
  "types": "./dist/hydrate.d.ts",
32
32
  "import": "./dist/hydrate.js"
33
+ },
34
+ "./server": {
35
+ "types": "./dist/server.d.ts",
36
+ "import": "./dist/server.js"
33
37
  }
34
38
  },
35
39
  "peerDependencies": {
package/src/browser.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Browser DX helpers — navigation + a typed `localStorage` wrapper.
3
+ *
4
+ * All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
5
+ * a no-op and storage reads return `null`, so the same code runs during SSR
6
+ * without `typeof window` guards at every call site. Node-free — part of the
7
+ * client barrel.
8
+ */
9
+
10
+ /** Navigate to `url` with a full page load. No-op during SSR. */
11
+ export function redirect(url: string): void {
12
+ if (typeof window !== "undefined") {
13
+ window.location.href = url;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Navigate to `url`, replacing the current history entry (no back-button trap —
19
+ * use after a login/logout so "back" doesn't return to the form). No-op on SSR.
20
+ */
21
+ export function replace(url: string): void {
22
+ if (typeof window !== "undefined") {
23
+ window.location.replace(url);
24
+ }
25
+ }
26
+
27
+ /** Reload the current page. No-op during SSR. */
28
+ export function reload(): void {
29
+ if (typeof window !== "undefined") {
30
+ window.location.reload();
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
36
+ * return `null` on the server, on a missing key, or on malformed JSON. Writes
37
+ * swallow quota / private-mode errors so a full storage never crashes the app.
38
+ */
39
+ export const storage = {
40
+ get<T>(key: string): T | null {
41
+ if (typeof localStorage === "undefined") return null;
42
+ const raw = localStorage.getItem(key);
43
+ if (raw === null) return null;
44
+ try {
45
+ return JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ },
50
+ set(key: string, value: unknown): void {
51
+ if (typeof localStorage === "undefined") return;
52
+ try {
53
+ localStorage.setItem(key, JSON.stringify(value));
54
+ } catch {
55
+ // QuotaExceededError / Safari private mode — best-effort write.
56
+ }
57
+ },
58
+ remove(key: string): void {
59
+ if (typeof localStorage !== "undefined") {
60
+ localStorage.removeItem(key);
61
+ }
62
+ },
63
+ clear(): void {
64
+ if (typeof localStorage !== "undefined") {
65
+ localStorage.clear();
66
+ }
67
+ },
68
+ };
package/src/component.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * actually moves on screen.
20
20
  */
21
21
 
22
+ import { setOwner } from "./reactive.js";
22
23
  import type { Disposer } from "./render.js";
23
24
  import type { EffectCallback, TemplateResult } from "./types.js";
24
25
 
@@ -63,10 +64,16 @@ export function component<P = Record<string, never>>(
63
64
  mountHooks: [],
64
65
  };
65
66
  contextStack.push(ctx);
67
+ // Own the reactive scope of setup: effects/memos created here register
68
+ // their disposer into ctx.cleanups and tear down at unmount, instead of
69
+ // leaking their signal subscriptions. Restored after setup so the outer
70
+ // scope (or none) resumes. Save/restore handles nested component() calls.
71
+ const prevOwner = setOwner(ctx.cleanups);
66
72
  try {
67
73
  const result = setup((props ?? ({} as P)) as P);
68
74
  return wrapWithLifecycle(result, ctx);
69
75
  } finally {
76
+ setOwner(prevOwner);
70
77
  contextStack.pop();
71
78
  }
72
79
  };
package/src/hydrate.ts CHANGED
@@ -422,12 +422,26 @@ function hydrateTextSlot(
422
422
  }
423
423
  return;
424
424
  }
425
- const textNode =
425
+ let textNode =
426
426
  commentMarker.nodeType === 3 /* TEXT */
427
427
  ? (commentMarker as Text)
428
428
  : commentMarker.previousSibling?.nodeType === 3
429
429
  ? (commentMarker.previousSibling as Text)
430
430
  : null;
431
+ if (
432
+ !textNode &&
433
+ commentMarker.nodeType === 8 /* Comment */ &&
434
+ commentMarker.parentNode
435
+ ) {
436
+ // Empty SSR text slot: a `<!---->` placeholder holds the position
437
+ // (see ssr.ts). Materialize the reactive text node there — node
438
+ // count stays 1, so sibling slot paths remain aligned.
439
+ const fresh = (commentMarker.ownerDocument ?? document).createTextNode(
440
+ "",
441
+ );
442
+ commentMarker.parentNode.replaceChild(fresh, commentMarker);
443
+ textNode = fresh;
444
+ }
431
445
  if (!textNode) return;
432
446
  const dispose = effect(() => {
433
447
  const v = fn();
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
- // ─── Inertia-shape server surface ─────────────────────────────────
2
- export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
1
+ // ─── Client surface (node-free safe to bundle for the browser) ──────
2
+ //
3
+ // Server-only exports (AuroraManager, Pages, renderPage, serveAssets) that pull
4
+ // node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
5
+ // this barrel is what lets a browser bundle import the client primitives without
6
+ // the bundler dragging Node built-ins through the import graph.
7
+ export { redirect, reload, replace, storage } from "./browser.js";
3
8
  export { component, onMount, onUnmount } from "./component.js";
4
9
  export { html, isTemplateResult } from "./html.js";
5
10
  export { hydrate } from "./hydrate.js";
6
- export {
7
- type PageFactory,
8
- Pages,
9
- type PagesConfig,
10
- } from "./Pages.js";
11
11
  export {
12
12
  batch,
13
13
  effect,
@@ -26,18 +26,5 @@ export {
26
26
  type AuroraRouteConfig,
27
27
  auroraRoute,
28
28
  } from "./route.js";
29
- export {
30
- type RenderHttpContext,
31
- type RenderPageOptions,
32
- type RenderResponse,
33
- renderPage,
34
- } from "./server/renderPage.js";
35
- export {
36
- type AssetsHttpContext,
37
- type AssetsRequest,
38
- type AssetsResponse,
39
- type ServeAssetsOptions,
40
- serveAssets,
41
- } from "./server/serveAssets.js";
42
29
  export { renderToString } from "./ssr.js";
43
30
  export type { TemplateResult } from "./types.js";
package/src/reactive.ts CHANGED
@@ -64,6 +64,28 @@ function activeObserver(): Effect | undefined {
64
64
  return observerStack[observerStack.length - 1];
65
65
  }
66
66
 
67
+ /**
68
+ * Ambient disposal owner. A non-reactive scope (e.g. a component's setup
69
+ * run) registers an array here so effects/memos created during its
70
+ * execution push their disposer into it and are torn down when the scope
71
+ * ends. `undefined` at top level — no scope, no auto-disposal.
72
+ */
73
+ let currentOwner: Array<() => void> | undefined;
74
+
75
+ /**
76
+ * @internal Swap the ambient owner, returning the previous one so the
77
+ * caller can restore it. `component()` uses this to own the effects and
78
+ * memos a setup function creates, so they dispose at unmount instead of
79
+ * keeping their signal subscriptions alive forever.
80
+ */
81
+ export function setOwner(
82
+ owner: Array<() => void> | undefined,
83
+ ): Array<() => void> | undefined {
84
+ const prev = currentOwner;
85
+ currentOwner = owner;
86
+ return prev;
87
+ }
88
+
67
89
  /**
68
90
  * Create a writable signal seeded with `initial`. Reads register the
69
91
  * current observer; writes notify every observer that previously read.
@@ -178,7 +200,13 @@ export function effect(fn: EffectCallback): () => void {
178
200
  },
179
201
  };
180
202
  eff.run();
181
- return () => eff.dispose();
203
+ const dispose = () => eff.dispose();
204
+ // Register with the ambient owner (e.g. a component's setup scope) so the
205
+ // effect is torn down when that scope ends. `memo()` builds on this — its
206
+ // internal recompute effect inherits the same ownership, which is what
207
+ // stops a memo created in component setup from leaking after unmount.
208
+ currentOwner?.push(dispose);
209
+ return dispose;
182
210
  }
183
211
 
184
212
  /**
package/src/relay.ts CHANGED
@@ -27,14 +27,12 @@ interface RelayState {
27
27
  sse: EventSource | null;
28
28
  uid: string | null;
29
29
  channels: Map<string, Set<(event: unknown) => void>>;
30
- pending: Array<() => void>;
31
30
  }
32
31
 
33
32
  const STATE: RelayState = {
34
33
  sse: null,
35
34
  uid: null,
36
35
  channels: new Map(),
37
- pending: [],
38
36
  };
39
37
 
40
38
  export interface RelayOptions {
@@ -87,15 +85,15 @@ const CLIENT: RelayClient = {
87
85
  const adapted = handler as (event: unknown) => void;
88
86
  handlers.add(adapted);
89
87
 
90
- // Subscribe over POST as soon as we have a uid. If the SSE is
91
- // still mid-handshake, queue the call and flush on `connected`.
92
- const doSubscribe = () => {
88
+ // Subscribe over POST as soon as we have a uid. Before the first uid (or
89
+ // during an auto-reconnect) the channel already lives in STATE.channels
90
+ // and is (re-)subscribed by the `connected` handler — so the server,
91
+ // which assigns a fresh uid per connection, always learns every channel.
92
+ if (STATE.uid) {
93
93
  postSubscribe(channel).catch((err: unknown) => {
94
94
  console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
95
95
  });
96
- };
97
- if (STATE.uid) doSubscribe();
98
- else STATE.pending.push(doSubscribe);
96
+ }
99
97
 
100
98
  // Detacher — only removes the local listener. The server-side
101
99
  // subscription stays open; closing it would interrupt other
@@ -112,7 +110,6 @@ const CLIENT: RelayClient = {
112
110
  }
113
111
  STATE.uid = null;
114
112
  STATE.channels.clear();
115
- STATE.pending.length = 0;
116
113
  },
117
114
  };
118
115
 
@@ -124,8 +121,19 @@ function open(): void {
124
121
  const data = safeJson<{ uid?: string }>((ev as MessageEvent).data);
125
122
  if (data && typeof data.uid === "string") {
126
123
  STATE.uid = data.uid;
127
- const queue = STATE.pending.splice(0);
128
- for (const fn of queue) fn();
124
+ // Re-apply EVERY active subscription on each (re)connect. The server
125
+ // assigns a fresh uid per connection and has no memory of prior
126
+ // subscriptions, so both the first connect AND browser auto-reconnects
127
+ // must re-POST every live channel — otherwise the client silently
128
+ // stops receiving after a reconnect.
129
+ for (const channel of STATE.channels.keys()) {
130
+ postSubscribe(channel).catch((err: unknown) => {
131
+ console.warn(
132
+ `[aurora/relay] re-subscribe to ${channel} failed:`,
133
+ err,
134
+ );
135
+ });
136
+ }
129
137
  }
130
138
  });
131
139
 
@@ -90,7 +90,7 @@ export async function renderPage<P>(
90
90
  <head>
91
91
  <meta charset="utf-8" />
92
92
  <meta name="viewport" content="width=device-width,initial-scale=1" />
93
- <script type="importmap">${JSON.stringify({ imports: importmap })}</script>
93
+ <script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
94
94
  ${options.headExtra ?? ""}
95
95
  </head>
96
96
  <body>
package/src/server.ts ADDED
@@ -0,0 +1,22 @@
1
+ // ─── Server-only surface (imports node:fs / node:path / node:url) ─────
2
+ //
3
+ // Kept OUT of the package's main barrel (`@c9up/aurora`) so a browser bundle
4
+ // importing client primitives (component/html/hydrate/render) never pulls the
5
+ // Node built-ins through the import graph. Server code imports from
6
+ // `@c9up/aurora/server`; the client `.` entry stays node-free.
7
+
8
+ export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
9
+ export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
10
+ export {
11
+ type RenderHttpContext,
12
+ type RenderPageOptions,
13
+ type RenderResponse,
14
+ renderPage,
15
+ } from "./server/renderPage.js";
16
+ export {
17
+ type AssetsHttpContext,
18
+ type AssetsRequest,
19
+ type AssetsResponse,
20
+ type ServeAssetsOptions,
21
+ serveAssets,
22
+ } from "./server/serveAssets.js";
package/src/ssr.ts CHANGED
@@ -78,8 +78,17 @@ function stringifyTemplateResult(result: TemplateResult): string {
78
78
  out += `<!--${SLOT_START}-->`;
79
79
  out += stringifyValue(value, false);
80
80
  out += `<!--${SLOT_END}-->`;
81
+ } else if (inAttr) {
82
+ out += stringifyValue(value, true);
81
83
  } else {
82
- out += stringifyValue(value, inAttr);
84
+ // Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
85
+ // would emit NO node and desync the path-based hydration of the
86
+ // following sibling slots (their @input/@submit bindings break).
87
+ // Emit an empty-comment placeholder so the position is preserved
88
+ // — lit-html / Solid do the same; hydration materializes the text
89
+ // node there.
90
+ const text = stringifyValue(value, false);
91
+ out += text === "" ? "<!---->" : text;
83
92
  }
84
93
  }
85
94
  }