@c9up/aurora 0.1.36 → 0.1.38

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.
@@ -20,10 +20,12 @@ import { isAbsolute, resolve as resolvePath } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { AuroraManager } from "./AuroraManager.js";
22
22
  import { auroraRoute } from "./route.js";
23
- import { setAurora } from "./services/main.js";
23
+ import { clearAurora, getAurora, setAurora } from "./services/main.js";
24
24
  import { renderToString } from "./ssr.js";
25
25
  export default class AuroraProvider {
26
26
  app;
27
+ /** What this provider bound, so shutdown only clears its own. */
28
+ #owned;
27
29
  constructor(app) {
28
30
  this.app = app;
29
31
  }
@@ -46,6 +48,7 @@ export default class AuroraProvider {
46
48
  // Force-resolve so `setAurora` runs even if the app never
47
49
  // touches the singleton from a preload.
48
50
  const manager = await this.app.container.resolve(AuroraManager);
51
+ this.#owned = manager;
49
52
  setAurora(manager);
50
53
  }
51
54
  async start() {
@@ -77,7 +80,15 @@ export default class AuroraProvider {
77
80
  }
78
81
  }
79
82
  async ready() { }
80
- async shutdown() { }
83
+ async shutdown() {
84
+ // Release the module-level singleton, while it is still ours. A stopped
85
+ // application left a dead Aurora manager reachable through `services/main`, and
86
+ // with two applications in one process the survivor's binding must not
87
+ // be the one cleared.
88
+ if (this.#owned !== undefined && getAurora() === this.#owned)
89
+ clearAurora();
90
+ this.#owned = undefined;
91
+ }
81
92
  /**
82
93
  * Resolve the user-supplied config:
83
94
  * - relative `pages.root` (e.g. `./resources/pages`) is joined to
@@ -15,9 +15,29 @@
15
15
  * simply inert.
16
16
  */
17
17
  import type { AuroraManager } from "./AuroraManager.js";
18
+ import type { AuroraRequestRenderer } from "./middleware.js";
18
19
  declare module "@c9up/ream/types" {
19
20
  interface ContainerBindings {
20
21
  /** The Aurora manager, bound by `AuroraProvider`. */
21
22
  aurora: AuroraManager;
22
23
  }
23
24
  }
25
+ declare module "@c9up/ream" {
26
+ interface HttpContext {
27
+ /**
28
+ * Render a page for THIS request — `ctx.aurora.render(name, props)`.
29
+ *
30
+ * Attached by the `auroraContext` middleware, which is what the docs tell
31
+ * an application to register. Without this declaration the property the
32
+ * middleware sets did not exist as far as the compiler was concerned, so
33
+ * the shorthand the documentation teaches did not typecheck, and a
34
+ * controller had to reach for the module-level `aurora.render(ctx, ...)`
35
+ * or assert its way past it.
36
+ *
37
+ * Optional, because the middleware is: an application that never
38
+ * registers it has no `ctx.aurora`, and saying otherwise would let a
39
+ * controller call something that is not there.
40
+ */
41
+ aurora?: AuroraRequestRenderer;
42
+ }
43
+ }
package/dist/html.js CHANGED
@@ -216,11 +216,90 @@ function collectSlots(root, classification) {
216
216
  visit(root.content, []);
217
217
  return slots;
218
218
  }
219
+ /**
220
+ * Elements that only ever exist inside `<svg>`.
221
+ *
222
+ * Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
223
+ * dialects — are deliberately absent: seeing one says nothing about which
224
+ * namespace was meant, and guessing wrong would move an ordinary anchor into
225
+ * SVG.
226
+ */
227
+ const SVG_ONLY = new Set([
228
+ "animate",
229
+ "animatemotion",
230
+ "animatetransform",
231
+ "circle",
232
+ "clippath",
233
+ "defs",
234
+ "desc",
235
+ "ellipse",
236
+ "feblend",
237
+ "fecolormatrix",
238
+ "fegaussianblur",
239
+ "femerge",
240
+ "feoffset",
241
+ "filter",
242
+ "foreignobject",
243
+ "g",
244
+ "image",
245
+ "line",
246
+ "lineargradient",
247
+ "marker",
248
+ "mask",
249
+ "path",
250
+ "pattern",
251
+ "polygon",
252
+ "polyline",
253
+ "radialgradient",
254
+ "rect",
255
+ "stop",
256
+ "svg",
257
+ "symbol",
258
+ "tspan",
259
+ "use",
260
+ ]);
261
+ /**
262
+ * Whether this markup is SVG content that lost its `<svg>` ancestor.
263
+ *
264
+ * A template compiled on its own — `html\`<path/><path/>\``, the body of an
265
+ * icon helper — is parsed with no parent, and the HTML parser has no
266
+ * self-closing tag for an unknown element: the second `<path>` becomes a CHILD
267
+ * of the first, in the XHTML namespace. Nothing throws and nothing is logged;
268
+ * the icon is simply invisible, because `<path>` in the wrong namespace paints
269
+ * nothing. Parsing the same markup inside an `<svg>` makes the parser apply
270
+ * foreign-content rules and produce the two siblings that were written.
271
+ *
272
+ * `<svg>` itself is excluded: the parser already handles it when it is the root
273
+ * of the markup, and wrapping one in another would nest them.
274
+ */
275
+ function isOrphanedSvgContent(root) {
276
+ const elements = Array.from(root.content.childNodes).filter((node) => node.nodeType === 1);
277
+ if (elements.length === 0)
278
+ return false;
279
+ return elements.every((el) => {
280
+ const name = el.localName.toLowerCase();
281
+ return name !== "svg" && SVG_ONLY.has(name);
282
+ });
283
+ }
219
284
  function compile(strings) {
220
285
  const classification = classifySlots(strings);
221
286
  const markup = buildMarkup(strings, classification);
222
- const tpl = document.createElement("template");
287
+ let tpl = document.createElement("template");
223
288
  tpl.innerHTML = markup;
289
+ if (isOrphanedSvgContent(tpl)) {
290
+ // Re-parsed with the ancestor the markup was written for, then lifted
291
+ // back out: the nodes keep the SVG namespace they were given, and the
292
+ // slot paths below are collected against the shape that will actually
293
+ // be cloned.
294
+ const wrapper = document.createElement("template");
295
+ wrapper.innerHTML = `<svg>${markup}</svg>`;
296
+ const svg = wrapper.content.firstElementChild;
297
+ if (svg !== null) {
298
+ tpl = document.createElement("template");
299
+ while (svg.firstChild)
300
+ tpl.content.appendChild(svg.firstChild);
301
+ }
302
+ }
224
303
  const slots = collectSlots(tpl, classification);
225
304
  return { element: tpl, slots };
226
305
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import "./augmentations.js";
2
1
  export type { CookieCodec, CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
3
2
  export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
4
3
  export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
package/dist/index.js CHANGED
@@ -4,7 +4,15 @@
4
4
  // node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
5
5
  // this barrel is what lets a browser bundle import the client primitives without
6
6
  // the bundler dragging Node built-ins through the import graph.
7
- import "./augmentations.js";
7
+ //
8
+ // `./augmentations.js` is NOT loaded here either, and for a related reason: it
9
+ // augments ream's HttpContext and ContainerBindings, so it names `@c9up/ream`
10
+ // and drags ream's whole source into any program that imports this barrel. A
11
+ // component library that only wants `component`/`html` was compiling ream's
12
+ // decorated console commands under its own tsconfig, which does not enable
13
+ // decorators — a build failure, in a package that never mentions ream. The
14
+ // augmentations load from `@c9up/aurora/server` and `@c9up/aurora/provider`,
15
+ // which is where every type they describe is reachable from anyway.
8
16
  export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
9
17
  export { clsx, cn, twMerge } from "./cn.js";
10
18
  export { command } from "./command.js";
package/dist/server.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import "./augmentations.js";
1
2
  export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
2
3
  export { type AuroraRequestRenderer, auroraContext, } from "./middleware.js";
3
4
  export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
package/dist/server.js CHANGED
@@ -4,6 +4,12 @@
4
4
  // importing client primitives (component/html/hydrate/render) never pulls the
5
5
  // Node built-ins through the import graph. Server code imports from
6
6
  // `@c9up/aurora/server`; the client `.` entry stays node-free.
7
+ //
8
+ // The ream augmentations (`ctx.aurora`, the `aurora` container binding) load
9
+ // here rather than from the client barrel: everything they describe is
10
+ // server-side, and naming `@c9up/ream` from the browser entry pulled ream's
11
+ // source into every consumer's program — including ones that never use it.
12
+ import "./augmentations.js";
7
13
  export { AuroraManager } from "./AuroraManager.js";
8
14
  export { auroraContext, } from "./middleware.js";
9
15
  export { Pages } from "./Pages.js";
@@ -14,5 +14,14 @@ import type { AuroraManager } from "../AuroraManager.js";
14
14
  export declare function setAurora(value: AuroraManager): void;
15
15
  /** @internal Read the singleton (or `undefined` pre-boot). */
16
16
  export declare function getAurora(): AuroraManager | undefined;
17
+ /**
18
+ * @internal Release the singleton, so a shut-down application does not leave a
19
+ * dead Aurora manager reachable through `services/main`.
20
+ *
21
+ * The caller checks ownership first (`getAurora() === mine`): two applications
22
+ * share this module in one process, and the one shutting down must not clear
23
+ * what the other has since bound.
24
+ */
25
+ export declare function clearAurora(): void;
17
26
  declare const aurora: AuroraManager;
18
27
  export default aurora;
@@ -18,6 +18,17 @@ export function setAurora(value) {
18
18
  export function getAurora() {
19
19
  return instance;
20
20
  }
21
+ /**
22
+ * @internal Release the singleton, so a shut-down application does not leave a
23
+ * dead Aurora manager reachable through `services/main`.
24
+ *
25
+ * The caller checks ownership first (`getAurora() === mine`): two applications
26
+ * share this module in one process, and the one shutting down must not clear
27
+ * what the other has since bound.
28
+ */
29
+ export function clearAurora() {
30
+ instance = undefined;
31
+ }
21
32
  const aurora = new Proxy({}, {
22
33
  get(_target, prop) {
23
34
  // A module loader inspects what it imports before anyone uses it: it reads
package/dist/ssr.js CHANGED
@@ -64,15 +64,37 @@ function stringifyTemplateResult(result) {
64
64
  // value written into the HTML, and any exception swallowed.
65
65
  const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
66
66
  const skipValue = directiveMatch !== null;
67
+ // Set when the skipped directive is a boolean attribute, which — unlike
68
+ // the other two — still has markup to emit. See below.
69
+ let booleanAttrName;
67
70
  if (directiveMatch) {
68
- const [whole = "", , quote] = directiveMatch;
71
+ const [whole = "", directive = "", quote] = directiveMatch;
69
72
  segment = segment.slice(0, segment.length - whole.length);
73
+ // `?disabled=${x}` is HTML STATE, not a client-only binding.
74
+ // `@click` is a listener and `.value` a DOM property: neither
75
+ // exists until the runtime binds it, so dropping them is right.
76
+ // A boolean attribute is different — the browser acts on it while
77
+ // parsing. Skipping it too made the server contradict the very
78
+ // first client render: a `?hidden` panel arrived visible and
79
+ // blinked away once hydration caught up.
80
+ if (directive.startsWith("?"))
81
+ booleanAttrName = directive.slice(1);
70
82
  // Only a quoted directive leaves a closing quote to swallow.
71
83
  pendingClosingQuote =
72
84
  quote === '"' ? '"' : quote === "'" ? "'" : undefined;
73
85
  }
74
86
  out += segment;
75
87
  scanner.consume(segment);
88
+ if (booleanAttrName !== undefined && i < values.length) {
89
+ // Present-and-empty when truthy, absent otherwise — byte-for-byte
90
+ // what applyBooleanAttrSlot writes on the client, so hydration
91
+ // re-applying the effect is a no-op instead of a correction.
92
+ if (resolveBooleanValue(values[i])) {
93
+ const rendered = ` ${booleanAttrName}=""`;
94
+ out += rendered;
95
+ scanner.consume(rendered);
96
+ }
97
+ }
76
98
  if (i < values.length && !skipValue) {
77
99
  const value = values[i];
78
100
  const inAttr = scanner.insideTag;
@@ -151,6 +173,26 @@ class TagScanner {
151
173
  return this.#inTag;
152
174
  }
153
175
  }
176
+ /**
177
+ * Read a boolean attribute's value the way the client reads it: a signal or a
178
+ * reactive expression is called ONCE, then coerced. One level is not an
179
+ * approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
180
+ * that returns a signal is truthy on both sides.
181
+ */
182
+ function resolveBooleanValue(value) {
183
+ if (isSignal(value) || typeof value === "function") {
184
+ try {
185
+ return Boolean(value());
186
+ }
187
+ catch {
188
+ // Same fail-soft as stringifyValue: an expression that throws
189
+ // server-side leaves the attribute off and lets the client effect
190
+ // decide once it has a real DOM to read.
191
+ return false;
192
+ }
193
+ }
194
+ return Boolean(value);
195
+ }
154
196
  function stringifyValue(value, inAttribute) {
155
197
  if (value === null || value === undefined || value === false)
156
198
  return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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",
@@ -58,6 +58,7 @@
58
58
  "@types/node": "^22.19.15",
59
59
  "@vitest/browser": "4.1.11",
60
60
  "@vitest/browser-playwright": "4.1.11",
61
+ "@vitest/coverage-v8": "4.1.9",
61
62
  "jsdom": "^30.0.1",
62
63
  "playwright": "^1.61.1",
63
64
  "typescript": "^6.0.2",
@@ -81,7 +82,7 @@
81
82
  "build": "tsc -p tsconfig.build.json",
82
83
  "typecheck": "tsc --noEmit",
83
84
  "test": "vitest run",
84
- "lint": "biome check src/",
85
+ "lint": "biome check src/ tests/",
85
86
  "test:coverage": "vitest run --coverage",
86
87
  "test:browser": "vitest run -c vitest.browser.config.ts"
87
88
  }
@@ -26,7 +26,7 @@ import type {
26
26
  AssetsRequest,
27
27
  AssetsResponse,
28
28
  } from "./server/serveAssets.js";
29
- import { setAurora } from "./services/main.js";
29
+ import { clearAurora, getAurora, setAurora } from "./services/main.js";
30
30
  import { renderToString } from "./ssr.js";
31
31
 
32
32
  interface AuroraContainer {
@@ -50,6 +50,9 @@ interface ReamRouter {
50
50
  }
51
51
 
52
52
  export default class AuroraProvider {
53
+ /** What this provider bound, so shutdown only clears its own. */
54
+ #owned: AuroraManager | undefined;
55
+
53
56
  constructor(protected app: AuroraAppContext) {}
54
57
 
55
58
  register(): void {
@@ -75,6 +78,7 @@ export default class AuroraProvider {
75
78
  // touches the singleton from a preload.
76
79
  const manager =
77
80
  await this.app.container.resolve<AuroraManager>(AuroraManager);
81
+ this.#owned = manager;
78
82
  setAurora(manager);
79
83
  }
80
84
 
@@ -114,7 +118,14 @@ export default class AuroraProvider {
114
118
  }
115
119
 
116
120
  async ready(): Promise<void> {}
117
- async shutdown(): Promise<void> {}
121
+ async shutdown(): Promise<void> {
122
+ // Release the module-level singleton, while it is still ours. A stopped
123
+ // application left a dead Aurora manager reachable through `services/main`, and
124
+ // with two applications in one process the survivor's binding must not
125
+ // be the one cleared.
126
+ if (this.#owned !== undefined && getAurora() === this.#owned) clearAurora();
127
+ this.#owned = undefined;
128
+ }
118
129
 
119
130
  /**
120
131
  * Resolve the user-supplied config:
@@ -15,10 +15,12 @@
15
15
  * simply inert.
16
16
  */
17
17
 
18
- // Referenced so the augmentation below resolves the module it augments.
18
+ // Referenced so the augmentations below resolve the modules they augment.
19
+ import type {} from "@c9up/ream";
19
20
  import type {} from "@c9up/ream/types";
20
21
 
21
22
  import type { AuroraManager } from "./AuroraManager.js";
23
+ import type { AuroraRequestRenderer } from "./middleware.js";
22
24
 
23
25
  declare module "@c9up/ream/types" {
24
26
  interface ContainerBindings {
@@ -26,3 +28,23 @@ declare module "@c9up/ream/types" {
26
28
  aurora: AuroraManager;
27
29
  }
28
30
  }
31
+
32
+ declare module "@c9up/ream" {
33
+ interface HttpContext {
34
+ /**
35
+ * Render a page for THIS request — `ctx.aurora.render(name, props)`.
36
+ *
37
+ * Attached by the `auroraContext` middleware, which is what the docs tell
38
+ * an application to register. Without this declaration the property the
39
+ * middleware sets did not exist as far as the compiler was concerned, so
40
+ * the shorthand the documentation teaches did not typecheck, and a
41
+ * controller had to reach for the module-level `aurora.render(ctx, ...)`
42
+ * or assert its way past it.
43
+ *
44
+ * Optional, because the middleware is: an application that never
45
+ * registers it has no `ctx.aurora`, and saying otherwise would let a
46
+ * controller call something that is not there.
47
+ */
48
+ aurora?: AuroraRequestRenderer;
49
+ }
50
+ }
package/src/html.ts CHANGED
@@ -259,11 +259,94 @@ function collectSlots(
259
259
  return slots;
260
260
  }
261
261
 
262
+ /**
263
+ * Elements that only ever exist inside `<svg>`.
264
+ *
265
+ * Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
266
+ * dialects — are deliberately absent: seeing one says nothing about which
267
+ * namespace was meant, and guessing wrong would move an ordinary anchor into
268
+ * SVG.
269
+ */
270
+ const SVG_ONLY = new Set([
271
+ "animate",
272
+ "animatemotion",
273
+ "animatetransform",
274
+ "circle",
275
+ "clippath",
276
+ "defs",
277
+ "desc",
278
+ "ellipse",
279
+ "feblend",
280
+ "fecolormatrix",
281
+ "fegaussianblur",
282
+ "femerge",
283
+ "feoffset",
284
+ "filter",
285
+ "foreignobject",
286
+ "g",
287
+ "image",
288
+ "line",
289
+ "lineargradient",
290
+ "marker",
291
+ "mask",
292
+ "path",
293
+ "pattern",
294
+ "polygon",
295
+ "polyline",
296
+ "radialgradient",
297
+ "rect",
298
+ "stop",
299
+ "svg",
300
+ "symbol",
301
+ "tspan",
302
+ "use",
303
+ ]);
304
+
305
+ /**
306
+ * Whether this markup is SVG content that lost its `<svg>` ancestor.
307
+ *
308
+ * A template compiled on its own — `html\`<path/><path/>\``, the body of an
309
+ * icon helper — is parsed with no parent, and the HTML parser has no
310
+ * self-closing tag for an unknown element: the second `<path>` becomes a CHILD
311
+ * of the first, in the XHTML namespace. Nothing throws and nothing is logged;
312
+ * the icon is simply invisible, because `<path>` in the wrong namespace paints
313
+ * nothing. Parsing the same markup inside an `<svg>` makes the parser apply
314
+ * foreign-content rules and produce the two siblings that were written.
315
+ *
316
+ * `<svg>` itself is excluded: the parser already handles it when it is the root
317
+ * of the markup, and wrapping one in another would nest them.
318
+ */
319
+ function isOrphanedSvgContent(root: HTMLTemplateElement): boolean {
320
+ const elements = Array.from(root.content.childNodes).filter(
321
+ (node): node is Element => node.nodeType === 1,
322
+ );
323
+ if (elements.length === 0) return false;
324
+ return elements.every((el) => {
325
+ const name = el.localName.toLowerCase();
326
+ return name !== "svg" && SVG_ONLY.has(name);
327
+ });
328
+ }
329
+
262
330
  function compile(strings: TemplateStringsArray): Template {
263
331
  const classification = classifySlots(strings);
264
332
  const markup = buildMarkup(strings, classification);
265
- const tpl = document.createElement("template");
333
+ let tpl = document.createElement("template");
266
334
  tpl.innerHTML = markup;
335
+
336
+ if (isOrphanedSvgContent(tpl)) {
337
+ // Re-parsed with the ancestor the markup was written for, then lifted
338
+ // back out: the nodes keep the SVG namespace they were given, and the
339
+ // slot paths below are collected against the shape that will actually
340
+ // be cloned.
341
+ const wrapper = document.createElement("template");
342
+ wrapper.innerHTML = `<svg>${markup}</svg>`;
343
+ const svg = wrapper.content.firstElementChild;
344
+ if (svg !== null) {
345
+ tpl = document.createElement("template");
346
+ while (svg.firstChild) tpl.content.appendChild(svg.firstChild);
347
+ }
348
+ }
349
+
267
350
  const slots = collectSlots(tpl, classification);
268
351
  return { element: tpl, slots };
269
352
  }
package/src/index.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  // node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
5
5
  // this barrel is what lets a browser bundle import the client primitives without
6
6
  // the bundler dragging Node built-ins through the import graph.
7
- import "./augmentations.js";
7
+ //
8
+ // `./augmentations.js` is NOT loaded here either, and for a related reason: it
9
+ // augments ream's HttpContext and ContainerBindings, so it names `@c9up/ream`
10
+ // and drags ream's whole source into any program that imports this barrel. A
11
+ // component library that only wants `component`/`html` was compiling ream's
12
+ // decorated console commands under its own tsconfig, which does not enable
13
+ // decorators — a build failure, in a package that never mentions ream. The
14
+ // augmentations load from `@c9up/aurora/server` and `@c9up/aurora/provider`,
15
+ // which is where every type they describe is reachable from anyway.
8
16
 
9
17
  export type {
10
18
  CookieCodec,
package/src/server.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  // importing client primitives (component/html/hydrate/render) never pulls the
5
5
  // Node built-ins through the import graph. Server code imports from
6
6
  // `@c9up/aurora/server`; the client `.` entry stays node-free.
7
+ //
8
+ // The ream augmentations (`ctx.aurora`, the `aurora` container binding) load
9
+ // here rather than from the client barrel: everything they describe is
10
+ // server-side, and naming `@c9up/ream` from the browser entry pulled ream's
11
+ // source into every consumer's program — including ones that never use it.
12
+ import "./augmentations.js";
7
13
 
8
14
  export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
9
15
  export {
@@ -24,6 +24,18 @@ export function getAurora(): AuroraManager | undefined {
24
24
  return instance;
25
25
  }
26
26
 
27
+ /**
28
+ * @internal Release the singleton, so a shut-down application does not leave a
29
+ * dead Aurora manager reachable through `services/main`.
30
+ *
31
+ * The caller checks ownership first (`getAurora() === mine`): two applications
32
+ * share this module in one process, and the one shutting down must not clear
33
+ * what the other has since bound.
34
+ */
35
+ export function clearAurora(): void {
36
+ instance = undefined;
37
+ }
38
+
27
39
  const aurora: AuroraManager = new Proxy({} as AuroraManager, {
28
40
  get(_target, prop) {
29
41
  // A module loader inspects what it imports before anyone uses it: it reads
package/src/ssr.ts CHANGED
@@ -68,15 +68,36 @@ function stringifyTemplateResult(result: TemplateResult): string {
68
68
  // value written into the HTML, and any exception swallowed.
69
69
  const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
70
70
  const skipValue = directiveMatch !== null;
71
+ // Set when the skipped directive is a boolean attribute, which — unlike
72
+ // the other two — still has markup to emit. See below.
73
+ let booleanAttrName: string | undefined;
71
74
  if (directiveMatch) {
72
- const [whole = "", , quote] = directiveMatch;
75
+ const [whole = "", directive = "", quote] = directiveMatch;
73
76
  segment = segment.slice(0, segment.length - whole.length);
77
+ // `?disabled=${x}` is HTML STATE, not a client-only binding.
78
+ // `@click` is a listener and `.value` a DOM property: neither
79
+ // exists until the runtime binds it, so dropping them is right.
80
+ // A boolean attribute is different — the browser acts on it while
81
+ // parsing. Skipping it too made the server contradict the very
82
+ // first client render: a `?hidden` panel arrived visible and
83
+ // blinked away once hydration caught up.
84
+ if (directive.startsWith("?")) booleanAttrName = directive.slice(1);
74
85
  // Only a quoted directive leaves a closing quote to swallow.
75
86
  pendingClosingQuote =
76
87
  quote === '"' ? '"' : quote === "'" ? "'" : undefined;
77
88
  }
78
89
  out += segment;
79
90
  scanner.consume(segment);
91
+ if (booleanAttrName !== undefined && i < values.length) {
92
+ // Present-and-empty when truthy, absent otherwise — byte-for-byte
93
+ // what applyBooleanAttrSlot writes on the client, so hydration
94
+ // re-applying the effect is a no-op instead of a correction.
95
+ if (resolveBooleanValue(values[i])) {
96
+ const rendered = ` ${booleanAttrName}=""`;
97
+ out += rendered;
98
+ scanner.consume(rendered);
99
+ }
100
+ }
80
101
  if (i < values.length && !skipValue) {
81
102
  const value = values[i];
82
103
  const inAttr = scanner.insideTag;
@@ -155,6 +176,26 @@ class TagScanner {
155
176
  }
156
177
  }
157
178
 
179
+ /**
180
+ * Read a boolean attribute's value the way the client reads it: a signal or a
181
+ * reactive expression is called ONCE, then coerced. One level is not an
182
+ * approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
183
+ * that returns a signal is truthy on both sides.
184
+ */
185
+ function resolveBooleanValue(value: unknown): boolean {
186
+ if (isSignal(value) || typeof value === "function") {
187
+ try {
188
+ return Boolean((value as () => unknown)());
189
+ } catch {
190
+ // Same fail-soft as stringifyValue: an expression that throws
191
+ // server-side leaves the attribute off and lets the client effect
192
+ // decide once it has a real DOM to read.
193
+ return false;
194
+ }
195
+ }
196
+ return Boolean(value);
197
+ }
198
+
158
199
  function stringifyValue(value: unknown, inAttribute: boolean): string {
159
200
  if (value === null || value === undefined || value === false) return "";
160
201
  if (value === true) return inAttribute ? "" : "true";