@c9up/aurora 0.1.38 → 0.1.39

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.
@@ -105,6 +105,12 @@ export declare class AuroraManager {
105
105
  /**
106
106
  * Handler for the app's pages directory. Mount on
107
107
  * `GET /__assets/pages/*`.
108
+ *
109
+ * `no-cache` while developing — which does NOT mean "do not cache", it
110
+ * means "always ask". These are source files: served with the default
111
+ * 60-second TTL and no validator, an edited page was handed back stale for
112
+ * a minute with no way for the browser even to enquire. The ETag makes the
113
+ * question cheap; production keeps the TTL.
108
114
  */
109
115
  pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void>;
110
116
  /**
@@ -129,9 +129,20 @@ export class AuroraManager {
129
129
  /**
130
130
  * Handler for the app's pages directory. Mount on
131
131
  * `GET /__assets/pages/*`.
132
+ *
133
+ * `no-cache` while developing — which does NOT mean "do not cache", it
134
+ * means "always ask". These are source files: served with the default
135
+ * 60-second TTL and no validator, an edited page was handed back stale for
136
+ * a minute with no way for the browser even to enquire. The ETag makes the
137
+ * question cheap; production keeps the TTL.
132
138
  */
133
139
  pageAssetsHandler() {
134
- return serveAssets({ root: this.pages.root });
140
+ return serveAssets({
141
+ root: this.pages.root,
142
+ ...(process.env.NODE_ENV === "production"
143
+ ? {}
144
+ : { cacheControl: "no-cache" }),
145
+ });
135
146
  }
136
147
  /**
137
148
  * Handler for `@c9up/comet`'s runtime (the RPC client). Mount on
@@ -52,8 +52,11 @@ export default class AuroraProvider {
52
52
  setAurora(manager);
53
53
  }
54
54
  async start() {
55
- // Asset routes are registered in `start()` after preloads — so apps can
56
- // swap aurora's pages root in a preload if they wanted to.
55
+ // Asset routes are registered in `start()`, which runs BEFORE the
56
+ // preloads providers start, then the `starting` hooks, then the
57
+ // preloads are imported. An earlier version of this comment claimed the
58
+ // opposite; an app that wanted to swap aurora's pages root has to do it
59
+ // from a provider, not from a preload.
57
60
  //
58
61
  // Resolve the host router from the container, where Ream registers it as
59
62
  // `'router'` (Ignitor). Reading it from the container — instead of
package/dist/form.d.ts CHANGED
@@ -43,7 +43,7 @@ export interface FormValidationOutcome {
43
43
  * Anything schema-shaped (a `@c9up/rune` schema satisfies this).
44
44
  *
45
45
  * Both spellings are accepted, and `validateResult` wins when present: rune
46
- * reserves `validate()` for the VineJS contract (async, throwing), and reading
46
+ * reserves `validate()` for the async, throwing contract, and reading
47
47
  * `.valid` off a Promise yields `undefined` — the form would then report itself
48
48
  * invalid with no error to show.
49
49
  */
package/dist/hydrate.js CHANGED
@@ -91,7 +91,10 @@ function renderValueToNodes(value, cleanups, mountHooks, doc) {
91
91
  return out;
92
92
  }
93
93
  if (isTemplateResult(value)) {
94
- const frag = mount(value, cleanups, [], mountHooks);
94
+ // The renderer collects hooks in a queue now; hydrate keeps its own
95
+ // flat list, so it hands one over and takes back what was collected.
96
+ const nested = { hooks: mountHooks, flushed: false };
97
+ const frag = mount(value, cleanups, [], nested);
95
98
  return Array.from(frag.childNodes);
96
99
  }
97
100
  if (value instanceof Node)
package/dist/render.d.ts CHANGED
@@ -9,6 +9,21 @@
9
9
  */
10
10
  import { type EffectCallback, type TemplateResult } from "./types.js";
11
11
  export type Disposer = () => void;
12
+ /**
13
+ * The `onMount` hooks collected while a tree is built, and whether they have
14
+ * already been run.
15
+ *
16
+ * A bare array was not enough. The root flushes it once, after the fragment is
17
+ * in the document; a reactive slot that swapped its content LATER kept
18
+ * appending to that same array, and nothing flushed it again — the component's
19
+ * setup ran, its `onMount` never did, and the hooks piled up for the life of
20
+ * the page. The flag is what lets a later update tell "collect these, the root
21
+ * will run them" from "the root is done, run mine myself".
22
+ */
23
+ interface MountQueue {
24
+ hooks: Array<EffectCallback>;
25
+ flushed: boolean;
26
+ }
12
27
  /**
13
28
  * Mount a TemplateResult into `container`. Returns a `Disposer` that
14
29
  * stops every reactive effect and removes the mounted nodes. Calling it
@@ -22,4 +37,5 @@ export declare function render(result: TemplateResult, container: Element | Docu
22
37
  * nested-template subtree when the signal changes after hydration
23
38
  * (the swap path — see `hydrateTextSlot`).
24
39
  */
25
- export declare function mount(result: TemplateResult, cleanups: Disposer[], mounted: ChildNode[], mountHooks: Array<EffectCallback>): DocumentFragment;
40
+ export declare function mount(result: TemplateResult, cleanups: Disposer[], mounted: ChildNode[], queue: MountQueue): DocumentFragment;
41
+ export {};
package/dist/render.js CHANGED
@@ -16,6 +16,24 @@ import { readComponentLifecycle } from "./component.js";
16
16
  import { getTemplate } from "./html.js";
17
17
  import { effect, isSignal } from "./reactive.js";
18
18
  import { isTemplateResult, } from "./types.js";
19
+ /**
20
+ * Run hooks, sending any teardown to `cleanups`.
21
+ *
22
+ * Failures are swallowed per hook: one component's bad `onMount` must not stop
23
+ * its siblings from mounting.
24
+ */
25
+ function runMountHooks(hooks, cleanups) {
26
+ for (const hook of hooks) {
27
+ try {
28
+ const teardown = hook();
29
+ if (typeof teardown === "function")
30
+ cleanups.push(teardown);
31
+ }
32
+ catch {
33
+ /* swallow — one bad onMount should not block sibling components */
34
+ }
35
+ }
36
+ }
19
37
  /**
20
38
  * Mount a TemplateResult into `container`. Returns a `Disposer` that
21
39
  * stops every reactive effect and removes the mounted nodes. Calling it
@@ -24,22 +42,17 @@ import { isTemplateResult, } from "./types.js";
24
42
  export function render(result, container) {
25
43
  const cleanups = [];
26
44
  const mountedNodes = [];
27
- const mountHooks = [];
28
- const fragment = mount(result, cleanups, mountedNodes, mountHooks);
45
+ const queue = { hooks: [], flushed: false };
46
+ const fragment = mount(result, cleanups, mountedNodes, queue);
29
47
  container.appendChild(fragment);
30
48
  // `onMount` hooks fire after the fragment is live in the document so
31
49
  // callbacks that measure / focus / observe see a real DOM. A returned
32
50
  // cleanup function joins the unmount queue.
33
- for (const hook of mountHooks) {
34
- try {
35
- const teardown = hook();
36
- if (typeof teardown === "function")
37
- cleanups.push(teardown);
38
- }
39
- catch {
40
- /* swallow — one bad onMount should not block sibling components */
41
- }
42
- }
51
+ runMountHooks(queue.hooks, cleanups);
52
+ queue.hooks = [];
53
+ // From here on the root never flushes again, so a slot that mounts
54
+ // something later has to run its own hooks.
55
+ queue.flushed = true;
43
56
  let disposed = false;
44
57
  return () => {
45
58
  if (disposed)
@@ -58,14 +71,14 @@ export function render(result, container) {
58
71
  * nested-template subtree when the signal changes after hydration
59
72
  * (the swap path — see `hydrateTextSlot`).
60
73
  */
61
- export function mount(result, cleanups, mounted, mountHooks) {
74
+ export function mount(result, cleanups, mounted, queue) {
62
75
  const tpl = getTemplate(result.strings);
63
76
  const fragment = tpl.element.content.cloneNode(true);
64
77
  // Forward any component()-attached lifecycle from this result.
65
78
  const lifecycle = readComponentLifecycle(result);
66
79
  if (lifecycle) {
67
80
  for (const hook of lifecycle.mountHooks)
68
- mountHooks.push(hook);
81
+ queue.hooks.push(hook);
69
82
  for (const c of lifecycle.cleanups)
70
83
  cleanups.push(c);
71
84
  }
@@ -106,7 +119,7 @@ export function mount(result, cleanups, mounted, mountHooks) {
106
119
  collectMultiAttr(slot, node, result.values[i], multiGroups);
107
120
  }
108
121
  else {
109
- applySlot(slot, node, result.values[i], cleanups, mounted, mountHooks);
122
+ applySlot(slot, node, result.values[i], cleanups, mounted, queue);
110
123
  }
111
124
  }
112
125
  for (const group of multiGroups.values()) {
@@ -134,10 +147,10 @@ function resolvePath(root, path) {
134
147
  }
135
148
  return node;
136
149
  }
137
- function applySlot(slot, node, value, cleanups, mounted, mountHooks) {
150
+ function applySlot(slot, node, value, cleanups, mounted, queue) {
138
151
  switch (slot.kind) {
139
152
  case "text":
140
- applyTextSlot(slot, node, value, cleanups, mounted, mountHooks);
153
+ applyTextSlot(slot, node, value, cleanups, mounted, queue);
141
154
  return;
142
155
  case "attr":
143
156
  applyAttrSlot(slot, node, value, cleanups);
@@ -159,7 +172,7 @@ function applySlot(slot, node, value, cleanups, mounted, mountHooks) {
159
172
  * inserted before it, and each re-render swaps out only the nodes it
160
173
  * previously inserted.
161
174
  */
162
- function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
175
+ function applyTextSlot(_slot, anchor, value, cleanups, mounted, queue) {
163
176
  let currentNodes = [];
164
177
  // Per-render disposers for whatever the slot currently shows. A
165
178
  // reactive slot that swaps a nested TemplateResult for another must
@@ -179,7 +192,16 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
179
192
  for (const n of currentNodes)
180
193
  n.remove();
181
194
  currentNodes = [];
182
- const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, mountHooks);
195
+ // This render's hooks are collected apart from the shared queue,
196
+ // whichever phase we are in, because their TEARDOWNS belong to this
197
+ // slot: the next swap must dispose what it replaces. Left on the root's
198
+ // list, a component mounted per row kept its subscription until the
199
+ // whole page unmounted.
200
+ //
201
+ // `flushed` is inherited so a deeper slot built during this render
202
+ // still knows whether the root has a flush coming.
203
+ const own = { hooks: [], flushed: queue.flushed };
204
+ const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, own);
183
205
  const parent = anchor.parentNode;
184
206
  if (!parent)
185
207
  return;
@@ -187,6 +209,20 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
187
209
  parent.insertBefore(n, anchor);
188
210
  currentNodes.push(n);
189
211
  }
212
+ if (queue.flushed) {
213
+ // After insertion, exactly as at the root: an `onMount` that
214
+ // measures, focuses or observes has to see a live node.
215
+ runMountHooks(own.hooks, localCleanups);
216
+ return;
217
+ }
218
+ // Still building the initial tree: the nodes are in a detached
219
+ // fragment, so defer to the root's flush. One wrapper carries them, and
220
+ // it returns nothing — the real teardowns go to `localCleanups`, not to
221
+ // the root.
222
+ const pending = own.hooks;
223
+ queue.hooks.push(() => {
224
+ runMountHooks(pending.splice(0), localCleanups);
225
+ });
190
226
  }
191
227
  if (isSignal(value) || typeof value === "function") {
192
228
  const dispose = effect(() => {
@@ -206,18 +242,18 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
206
242
  cleanups.push(disposeLocal);
207
243
  }
208
244
  }
209
- function renderValueIntoNodes(value, cleanups, mounted, mountHooks) {
245
+ function renderValueIntoNodes(value, cleanups, mounted, queue) {
210
246
  if (value === null || value === undefined || value === false)
211
247
  return [];
212
248
  if (Array.isArray(value)) {
213
249
  const out = [];
214
250
  for (const item of value) {
215
- out.push(...renderValueIntoNodes(item, cleanups, mounted, mountHooks));
251
+ out.push(...renderValueIntoNodes(item, cleanups, mounted, queue));
216
252
  }
217
253
  return out;
218
254
  }
219
255
  if (isTemplateResult(value)) {
220
- const frag = mount(value, cleanups, mounted, mountHooks);
256
+ const frag = mount(value, cleanups, mounted, queue);
221
257
  return Array.from(frag.childNodes);
222
258
  }
223
259
  if (value instanceof Node) {
@@ -28,6 +28,12 @@ export interface AssetsRequest {
28
28
  * convention.
29
29
  */
30
30
  param(name: string): unknown;
31
+ /**
32
+ * Read a request header. OPTIONAL, so a host that only implements
33
+ * `param()` keeps working: without it there is no conditional request and
34
+ * every response is a full 200, which is what happened before.
35
+ */
36
+ header?(name: string): string | undefined;
31
37
  }
32
38
  export interface AssetsResponse {
33
39
  status(code: number): AssetsResponse;
@@ -48,6 +54,10 @@ export interface ServeAssetsOptions {
48
54
  * `Cache-Control` value to emit. Defaults to a dev-friendly
49
55
  * 60-second TTL. Production deployments should hash the asset
50
56
  * name and switch to `public, max-age=31536000, immutable`.
57
+ *
58
+ * Note that a TTL alone tells the browser not to ASK for 60 seconds. Pair
59
+ * it with `no-cache` while developing — the ETag below then makes the
60
+ * revalidation nearly free.
51
61
  */
52
62
  cacheControl?: string;
53
63
  }
@@ -9,6 +9,7 @@
9
9
  * and writes to `ctx.response`. Any context that satisfies
10
10
  * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
11
  */
12
+ import { createHash } from "node:crypto";
12
13
  import { readFile, realpath } from "node:fs/promises";
13
14
  import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
@@ -25,6 +26,27 @@ import { fileURLToPath } from "node:url";
25
26
  export function packageAssetDir(specifier) {
26
27
  return dirname(fileURLToPath(import.meta.resolve(specifier)));
27
28
  }
29
+ /**
30
+ * Does `If-None-Match` cover this entity? (RFC 9110 §13.1.2)
31
+ *
32
+ * A strict `===` answered 200 for three shapes a real client sends: `*`, a
33
+ * comma-separated list of tags, and the weak form `W/"…"` — so a browser
34
+ * holding the exact bytes re-downloaded them anyway, which is most of what the
35
+ * validator exists to prevent. Written here rather than imported because aurora
36
+ * does not depend on ream; the same function lives in `ream/src/http/etag.ts`.
37
+ */
38
+ function matchesIfNoneMatch(header, tag) {
39
+ if (header === undefined || header === "" || tag === "")
40
+ return false;
41
+ // `*` means "any current representation", so a stored copy always matches.
42
+ if (header.trim() === "*")
43
+ return true;
44
+ const bare = (value) => value.startsWith("W/") ? value.slice(2) : value;
45
+ const current = bare(tag);
46
+ return header
47
+ .split(",")
48
+ .some((candidate) => bare(candidate.trim()) === current);
49
+ }
28
50
  const CONTENT_TYPES = {
29
51
  ".js": "text/javascript; charset=utf-8",
30
52
  ".mjs": "text/javascript; charset=utf-8",
@@ -98,9 +120,25 @@ export function serveAssets(options) {
98
120
  ctx.response.status(404).send("asset not found");
99
121
  return;
100
122
  }
123
+ // A validator, so a cached copy can be CHECKED rather than only trusted
124
+ // for a fixed time. Without one an edited module was served stale for
125
+ // the whole TTL with no way for the browser to ask whether it changed —
126
+ // in development that is a source file, and the answer is usually yes.
127
+ //
128
+ // Hashed from the bytes actually being sent rather than from mtime and
129
+ // size: a checkout, a rebuild or a touched file all move the metadata
130
+ // without changing the content, and each would needlessly re-download.
131
+ const etag = `"${createHash("sha1").update(body).digest("base64url")}"`;
101
132
  const type = CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
102
133
  ctx.response.header("content-type", type);
103
134
  ctx.response.header("cache-control", cacheControl);
135
+ ctx.response.header("etag", etag);
136
+ if (matchesIfNoneMatch(ctx.request.header?.("if-none-match"), etag)) {
137
+ // 304 carries no body, and must not: the browser reuses the copy it
138
+ // already has.
139
+ ctx.response.status(304).send("");
140
+ return;
141
+ }
104
142
  ctx.response.send(body);
105
143
  };
106
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
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",
@@ -209,9 +209,20 @@ export class AuroraManager {
209
209
  /**
210
210
  * Handler for the app's pages directory. Mount on
211
211
  * `GET /__assets/pages/*`.
212
+ *
213
+ * `no-cache` while developing — which does NOT mean "do not cache", it
214
+ * means "always ask". These are source files: served with the default
215
+ * 60-second TTL and no validator, an edited page was handed back stale for
216
+ * a minute with no way for the browser even to enquire. The ETag makes the
217
+ * question cheap; production keeps the TTL.
212
218
  */
213
219
  pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void> {
214
- return serveAssets({ root: this.pages.root });
220
+ return serveAssets({
221
+ root: this.pages.root,
222
+ ...(process.env.NODE_ENV === "production"
223
+ ? {}
224
+ : { cacheControl: "no-cache" }),
225
+ });
215
226
  }
216
227
 
217
228
  /**
@@ -83,8 +83,11 @@ export default class AuroraProvider {
83
83
  }
84
84
 
85
85
  async start(): Promise<void> {
86
- // Asset routes are registered in `start()` after preloads — so apps can
87
- // swap aurora's pages root in a preload if they wanted to.
86
+ // Asset routes are registered in `start()`, which runs BEFORE the
87
+ // preloads providers start, then the `starting` hooks, then the
88
+ // preloads are imported. An earlier version of this comment claimed the
89
+ // opposite; an app that wanted to swap aurora's pages root has to do it
90
+ // from a provider, not from a preload.
88
91
  //
89
92
  // Resolve the host router from the container, where Ream registers it as
90
93
  // `'router'` (Ignitor). Reading it from the container — instead of
package/src/form.ts CHANGED
@@ -45,7 +45,7 @@ export interface FormValidationOutcome {
45
45
  * Anything schema-shaped (a `@c9up/rune` schema satisfies this).
46
46
  *
47
47
  * Both spellings are accepted, and `validateResult` wins when present: rune
48
- * reserves `validate()` for the VineJS contract (async, throwing), and reading
48
+ * reserves `validate()` for the async, throwing contract, and reading
49
49
  * `.valid` off a Promise yields `undefined` — the form would then report itself
50
50
  * invalid with no error to show.
51
51
  */
package/src/hydrate.ts CHANGED
@@ -143,7 +143,10 @@ function renderValueToNodes(
143
143
  return out;
144
144
  }
145
145
  if (isTemplateResult(value)) {
146
- const frag = mount(value, cleanups, [], mountHooks);
146
+ // The renderer collects hooks in a queue now; hydrate keeps its own
147
+ // flat list, so it hands one over and takes back what was collected.
148
+ const nested = { hooks: mountHooks, flushed: false };
149
+ const frag = mount(value, cleanups, [], nested);
147
150
  return Array.from(frag.childNodes);
148
151
  }
149
152
  if (value instanceof Node) return [value as ChildNode];
package/src/render.ts CHANGED
@@ -31,6 +31,42 @@ import {
31
31
 
32
32
  export type Disposer = () => void;
33
33
 
34
+ /**
35
+ * The `onMount` hooks collected while a tree is built, and whether they have
36
+ * already been run.
37
+ *
38
+ * A bare array was not enough. The root flushes it once, after the fragment is
39
+ * in the document; a reactive slot that swapped its content LATER kept
40
+ * appending to that same array, and nothing flushed it again — the component's
41
+ * setup ran, its `onMount` never did, and the hooks piled up for the life of
42
+ * the page. The flag is what lets a later update tell "collect these, the root
43
+ * will run them" from "the root is done, run mine myself".
44
+ */
45
+ interface MountQueue {
46
+ hooks: Array<EffectCallback>;
47
+ flushed: boolean;
48
+ }
49
+
50
+ /**
51
+ * Run hooks, sending any teardown to `cleanups`.
52
+ *
53
+ * Failures are swallowed per hook: one component's bad `onMount` must not stop
54
+ * its siblings from mounting.
55
+ */
56
+ function runMountHooks(
57
+ hooks: Array<EffectCallback>,
58
+ cleanups: Disposer[],
59
+ ): void {
60
+ for (const hook of hooks) {
61
+ try {
62
+ const teardown = hook();
63
+ if (typeof teardown === "function") cleanups.push(teardown);
64
+ } catch {
65
+ /* swallow — one bad onMount should not block sibling components */
66
+ }
67
+ }
68
+ }
69
+
34
70
  /**
35
71
  * Mount a TemplateResult into `container`. Returns a `Disposer` that
36
72
  * stops every reactive effect and removes the mounted nodes. Calling it
@@ -42,22 +78,19 @@ export function render(
42
78
  ): Disposer {
43
79
  const cleanups: Disposer[] = [];
44
80
  const mountedNodes: ChildNode[] = [];
45
- const mountHooks: Array<EffectCallback> = [];
81
+ const queue: MountQueue = { hooks: [], flushed: false };
46
82
 
47
- const fragment = mount(result, cleanups, mountedNodes, mountHooks);
83
+ const fragment = mount(result, cleanups, mountedNodes, queue);
48
84
  container.appendChild(fragment);
49
85
 
50
86
  // `onMount` hooks fire after the fragment is live in the document so
51
87
  // callbacks that measure / focus / observe see a real DOM. A returned
52
88
  // cleanup function joins the unmount queue.
53
- for (const hook of mountHooks) {
54
- try {
55
- const teardown = hook();
56
- if (typeof teardown === "function") cleanups.push(teardown);
57
- } catch {
58
- /* swallow — one bad onMount should not block sibling components */
59
- }
60
- }
89
+ runMountHooks(queue.hooks, cleanups);
90
+ queue.hooks = [];
91
+ // From here on the root never flushes again, so a slot that mounts
92
+ // something later has to run its own hooks.
93
+ queue.flushed = true;
61
94
 
62
95
  let disposed = false;
63
96
  return () => {
@@ -92,7 +125,7 @@ export function mount(
92
125
  result: TemplateResult,
93
126
  cleanups: Disposer[],
94
127
  mounted: ChildNode[],
95
- mountHooks: Array<EffectCallback>,
128
+ queue: MountQueue,
96
129
  ): DocumentFragment {
97
130
  const tpl = getTemplate(result.strings);
98
131
  const fragment = tpl.element.content.cloneNode(true) as DocumentFragment;
@@ -100,7 +133,7 @@ export function mount(
100
133
  // Forward any component()-attached lifecycle from this result.
101
134
  const lifecycle = readComponentLifecycle(result);
102
135
  if (lifecycle) {
103
- for (const hook of lifecycle.mountHooks) mountHooks.push(hook);
136
+ for (const hook of lifecycle.mountHooks) queue.hooks.push(hook);
104
137
  for (const c of lifecycle.cleanups) cleanups.push(c);
105
138
  }
106
139
 
@@ -148,7 +181,7 @@ export function mount(
148
181
  if (slot.kind === "attr" && slot.staticParts !== undefined) {
149
182
  collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
150
183
  } else {
151
- applySlot(slot, node, result.values[i], cleanups, mounted, mountHooks);
184
+ applySlot(slot, node, result.values[i], cleanups, mounted, queue);
152
185
  }
153
186
  }
154
187
 
@@ -185,18 +218,11 @@ function applySlot(
185
218
  value: unknown,
186
219
  cleanups: Disposer[],
187
220
  mounted: ChildNode[],
188
- mountHooks: Array<EffectCallback>,
221
+ queue: MountQueue,
189
222
  ): void {
190
223
  switch (slot.kind) {
191
224
  case "text":
192
- applyTextSlot(
193
- slot,
194
- node as Comment,
195
- value,
196
- cleanups,
197
- mounted,
198
- mountHooks,
199
- );
225
+ applyTextSlot(slot, node as Comment, value, cleanups, mounted, queue);
200
226
  return;
201
227
  case "attr":
202
228
  applyAttrSlot(slot, node as Element, value, cleanups);
@@ -225,7 +251,7 @@ function applyTextSlot(
225
251
  value: unknown,
226
252
  cleanups: Disposer[],
227
253
  mounted: ChildNode[],
228
- mountHooks: Array<EffectCallback>,
254
+ queue: MountQueue,
229
255
  ): void {
230
256
  let currentNodes: ChildNode[] = [];
231
257
  // Per-render disposers for whatever the slot currently shows. A
@@ -246,18 +272,36 @@ function applyTextSlot(
246
272
  disposeLocal();
247
273
  for (const n of currentNodes) n.remove();
248
274
  currentNodes = [];
249
- const nodes = renderValueIntoNodes(
250
- newValue,
251
- localCleanups,
252
- mounted,
253
- mountHooks,
254
- );
275
+ // This render's hooks are collected apart from the shared queue,
276
+ // whichever phase we are in, because their TEARDOWNS belong to this
277
+ // slot: the next swap must dispose what it replaces. Left on the root's
278
+ // list, a component mounted per row kept its subscription until the
279
+ // whole page unmounted.
280
+ //
281
+ // `flushed` is inherited so a deeper slot built during this render
282
+ // still knows whether the root has a flush coming.
283
+ const own: MountQueue = { hooks: [], flushed: queue.flushed };
284
+ const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, own);
255
285
  const parent = anchor.parentNode;
256
286
  if (!parent) return;
257
287
  for (const n of nodes) {
258
288
  parent.insertBefore(n, anchor);
259
289
  currentNodes.push(n);
260
290
  }
291
+ if (queue.flushed) {
292
+ // After insertion, exactly as at the root: an `onMount` that
293
+ // measures, focuses or observes has to see a live node.
294
+ runMountHooks(own.hooks, localCleanups);
295
+ return;
296
+ }
297
+ // Still building the initial tree: the nodes are in a detached
298
+ // fragment, so defer to the root's flush. One wrapper carries them, and
299
+ // it returns nothing — the real teardowns go to `localCleanups`, not to
300
+ // the root.
301
+ const pending = own.hooks;
302
+ queue.hooks.push(() => {
303
+ runMountHooks(pending.splice(0), localCleanups);
304
+ });
261
305
  }
262
306
 
263
307
  if (isSignal(value) || typeof value === "function") {
@@ -282,18 +326,18 @@ function renderValueIntoNodes(
282
326
  value: unknown,
283
327
  cleanups: Disposer[],
284
328
  mounted: ChildNode[],
285
- mountHooks: Array<EffectCallback>,
329
+ queue: MountQueue,
286
330
  ): ChildNode[] {
287
331
  if (value === null || value === undefined || value === false) return [];
288
332
  if (Array.isArray(value)) {
289
333
  const out: ChildNode[] = [];
290
334
  for (const item of value) {
291
- out.push(...renderValueIntoNodes(item, cleanups, mounted, mountHooks));
335
+ out.push(...renderValueIntoNodes(item, cleanups, mounted, queue));
292
336
  }
293
337
  return out;
294
338
  }
295
339
  if (isTemplateResult(value)) {
296
- const frag = mount(value, cleanups, mounted, mountHooks);
340
+ const frag = mount(value, cleanups, mounted, queue);
297
341
  return Array.from(frag.childNodes);
298
342
  }
299
343
  if (value instanceof Node) {
@@ -10,6 +10,7 @@
10
10
  * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto";
13
14
  import { readFile, realpath } from "node:fs/promises";
14
15
  import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
@@ -28,6 +29,27 @@ export function packageAssetDir(specifier: string): string {
28
29
  return dirname(fileURLToPath(import.meta.resolve(specifier)));
29
30
  }
30
31
 
32
+ /**
33
+ * Does `If-None-Match` cover this entity? (RFC 9110 §13.1.2)
34
+ *
35
+ * A strict `===` answered 200 for three shapes a real client sends: `*`, a
36
+ * comma-separated list of tags, and the weak form `W/"…"` — so a browser
37
+ * holding the exact bytes re-downloaded them anyway, which is most of what the
38
+ * validator exists to prevent. Written here rather than imported because aurora
39
+ * does not depend on ream; the same function lives in `ream/src/http/etag.ts`.
40
+ */
41
+ function matchesIfNoneMatch(header: string | undefined, tag: string): boolean {
42
+ if (header === undefined || header === "" || tag === "") return false;
43
+ // `*` means "any current representation", so a stored copy always matches.
44
+ if (header.trim() === "*") return true;
45
+ const bare = (value: string): string =>
46
+ value.startsWith("W/") ? value.slice(2) : value;
47
+ const current = bare(tag);
48
+ return header
49
+ .split(",")
50
+ .some((candidate) => bare(candidate.trim()) === current);
51
+ }
52
+
31
53
  const CONTENT_TYPES: Record<string, string> = {
32
54
  ".js": "text/javascript; charset=utf-8",
33
55
  ".mjs": "text/javascript; charset=utf-8",
@@ -44,6 +66,12 @@ export interface AssetsRequest {
44
66
  * convention.
45
67
  */
46
68
  param(name: string): unknown;
69
+ /**
70
+ * Read a request header. OPTIONAL, so a host that only implements
71
+ * `param()` keeps working: without it there is no conditional request and
72
+ * every response is a full 200, which is what happened before.
73
+ */
74
+ header?(name: string): string | undefined;
47
75
  }
48
76
  export interface AssetsResponse {
49
77
  status(code: number): AssetsResponse;
@@ -65,6 +93,10 @@ export interface ServeAssetsOptions {
65
93
  * `Cache-Control` value to emit. Defaults to a dev-friendly
66
94
  * 60-second TTL. Production deployments should hash the asset
67
95
  * name and switch to `public, max-age=31536000, immutable`.
96
+ *
97
+ * Note that a TTL alone tells the browser not to ASK for 60 seconds. Pair
98
+ * it with `no-cache` while developing — the ETag below then makes the
99
+ * revalidation nearly free.
68
100
  */
69
101
  cacheControl?: string;
70
102
  }
@@ -144,10 +176,26 @@ export function serveAssets(
144
176
  return;
145
177
  }
146
178
 
179
+ // A validator, so a cached copy can be CHECKED rather than only trusted
180
+ // for a fixed time. Without one an edited module was served stale for
181
+ // the whole TTL with no way for the browser to ask whether it changed —
182
+ // in development that is a source file, and the answer is usually yes.
183
+ //
184
+ // Hashed from the bytes actually being sent rather than from mtime and
185
+ // size: a checkout, a rebuild or a touched file all move the metadata
186
+ // without changing the content, and each would needlessly re-download.
187
+ const etag = `"${createHash("sha1").update(body).digest("base64url")}"`;
147
188
  const type =
148
189
  CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
149
190
  ctx.response.header("content-type", type);
150
191
  ctx.response.header("cache-control", cacheControl);
192
+ ctx.response.header("etag", etag);
193
+ if (matchesIfNoneMatch(ctx.request.header?.("if-none-match"), etag)) {
194
+ // 304 carries no body, and must not: the browser reuses the copy it
195
+ // already has.
196
+ ctx.response.status(304).send("");
197
+ return;
198
+ }
151
199
  ctx.response.send(body);
152
200
  };
153
201
  }