@c9up/aurora 0.1.5 → 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.
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
  };
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.5",
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",
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/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>