@webjsdev/server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. package/src/elision-report.js +0 -193
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The dev component hot swap, BROWSER half (#1689). A browser-safe,
3
+ * import-free module that `reloadClientJs` (`dev/helpers.js`) inlines into
4
+ * `/__webjs/reload.js` after an `export`-keyword strip, like `dev-styles.js`,
5
+ * so the browser test drives the exact code that ships. Production never loads
6
+ * it, so `@webjsdev/core`'s `hot.js` reads find nothing there.
7
+ *
8
+ * `installHotClient(globalThis)` publishes `{ strings, replace, applyModules }`
9
+ * on `globalThis[Symbol.for('webjs.hot')]`, which core reads:
10
+ *
11
+ * - `strings(arr, kind)` interns template strings by their text, so the
12
+ * re-evaluated module's unchanged templates keep the identity the reconciler
13
+ * compares (`prev.strings === tr.strings`) and update in place, keeping
14
+ * their DOM (focus, an input's value, a nested component), while a template
15
+ * whose text changed rebuilds.
16
+ * - `replace(tag, Old, New)` is what core's `customElements.define` wrapper
17
+ * calls on a second define of a known tag. The native registry keeps the
18
+ * class and the lifecycle callbacks it read at define time, so the new
19
+ * class's members are copied onto the REGISTERED one, level by level up to
20
+ * the first class both chains share, and every live instance re-renders.
21
+ * Reactive values live per instance in `__propValues` and instance signals in
22
+ * fields, so both survive. A change the native registry would ignore throws
23
+ * before anything is patched, and the reload client reloads with the reason.
24
+ * - `applyModules(urls)` imports the versioned component modules a
25
+ * `component` verdict lists, in order, and reports the tags it swapped.
26
+ *
27
+ * The reload script runs before the page's boot script (`ssr/head.js`), so the
28
+ * interner sees every template from the first evaluation on.
29
+ */
30
+
31
+ /** Callbacks the native registry captures at define time. */
32
+ const HOT_NATIVE_CALLBACKS = [
33
+ 'connectedCallback', 'disconnectedCallback', 'adoptedCallback', 'attributeChangedCallback',
34
+ 'connectedMoveCallback', 'formAssociatedCallback', 'formResetCallback', 'formDisabledCallback',
35
+ 'formStateRestoreCallback',
36
+ ];
37
+
38
+ /** Statics that are part of a class's identity or compared by a guard, never copied. */
39
+ const HOT_FIXED_STATICS = ['prototype', 'name', 'length', 'properties', 'elementProperties', 'elementStyles', 'observedAttributes', 'finalized'];
40
+
41
+ /** @param {any} C */
42
+ function hotChain(C) {
43
+ const out = [];
44
+ for (let c = C; c && c !== Function.prototype; c = Object.getPrototypeOf(c)) out.push(c);
45
+ return out;
46
+ }
47
+
48
+ /** @param {unknown} v */
49
+ function hotText(v) {
50
+ if (v === undefined) return '';
51
+ if (typeof v === 'function') return String(v);
52
+ if (v && typeof v === 'object') {
53
+ const o = /** @type {Record<string, unknown>} */ (v);
54
+ return '{' + Object.keys(o).sort().map((k) => k + ':' + hotText(o[k])).join(',') + '}';
55
+ }
56
+ try { return JSON.stringify(v); } catch (_) { return String(v); }
57
+ }
58
+
59
+ /**
60
+ * The declared reactive property shape of a class, as one comparable string,
61
+ * plus the names (for the patch to skip their prototype accessors).
62
+ * @param {any} C
63
+ */
64
+ function hotShape(C) {
65
+ /** @type {Record<string, unknown>} */
66
+ const all = {};
67
+ for (const c of hotChain(C).reverse()) {
68
+ if (Object.prototype.hasOwnProperty.call(c, 'properties') && c.properties && typeof c.properties === 'object') Object.assign(all, c.properties);
69
+ }
70
+ if (C.elementProperties instanceof Map) for (const [k, v] of C.elementProperties) all[k] = v;
71
+ const names = Object.keys(all).sort();
72
+ const text = names.map((name) => {
73
+ const raw = all[name];
74
+ const d = /** @type {any} */ (raw && typeof raw === 'object' ? raw : { type: raw });
75
+ return [name, d.type && d.type.name, hotText(d.attribute), !!d.reflect, !!d.state, !!d.noAccessor,
76
+ hotText(d.converter), hotText(d.hasChanged), hotText(d.default)].join('|');
77
+ }).join('\n');
78
+ return { names, text };
79
+ }
80
+
81
+ /** @param {any} C */
82
+ function hotObserved(C) {
83
+ try { return [...(C.observedAttributes || [])].map(String).sort().join(','); } catch (_) { return '?'; }
84
+ }
85
+
86
+ /** @param {unknown} s @returns {CSSStyleSheet | null} */
87
+ function hotSheet(s) {
88
+ if (typeof CSSStyleSheet !== 'undefined' && s instanceof CSSStyleSheet) return s;
89
+ const o = /** @type {any} */ (s);
90
+ if (o && typeof CSSStyleSheet !== 'undefined' && o.styleSheet instanceof CSSStyleSheet) return o.styleSheet;
91
+ if (o && typeof o.text === 'string' && typeof CSSStyleSheet !== 'undefined') {
92
+ const sheet = new CSSStyleSheet();
93
+ sheet.replaceSync(o.text);
94
+ return sheet;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Every element in `root` (and in each open shadow root below it) that is an
101
+ * instance of `C`, including instances of a subclass.
102
+ * @param {Document | ShadowRoot | Element} root
103
+ * @param {Function} C
104
+ * @param {Element[]} [out]
105
+ */
106
+ function hotInstances(root, C, out = []) {
107
+ for (const el of root.querySelectorAll('*')) {
108
+ if (el instanceof C) out.push(el);
109
+ if (el.shadowRoot) hotInstances(el.shadowRoot, C, out);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The reason `New` cannot be patched onto the registered `Old`, or null.
116
+ * @param {any} Old
117
+ * @param {any} New
118
+ * @returns {{ why: string, depth: number }}
119
+ */
120
+ function hotCheck(Old, New) {
121
+ const oldChain = hotChain(Old);
122
+ const newChain = hotChain(New);
123
+ const depth = newChain.findIndex((c) => oldChain.includes(c));
124
+ if (depth === -1 || oldChain.indexOf(newChain[depth]) !== depth) return { why: 'class-chain-changed', depth };
125
+ if (hotShape(Old).text !== hotShape(New).text) return { why: 'reactive-properties-changed', depth };
126
+ if (hotObserved(Old) !== hotObserved(New)) return { why: 'observed-attributes-changed', depth };
127
+ if (Old.shadow !== New.shadow) return { why: 'static-shadow-changed', depth };
128
+ if (Old.formAssociated !== New.formAssociated) return { why: 'form-associated-changed', depth };
129
+ for (let i = 0; i < depth; i++) {
130
+ for (const cb of HOT_NATIVE_CALLBACKS) {
131
+ const o = Object.getOwnPropertyDescriptor(oldChain[i].prototype, cb);
132
+ const n = Object.getOwnPropertyDescriptor(newChain[i].prototype, cb);
133
+ if (!!o !== !!n || (o && n && String(o.value) !== String(n.value))) return { why: cb + '-changed', depth };
134
+ }
135
+ }
136
+ return { why: '', depth };
137
+ }
138
+
139
+ /**
140
+ * Copy `from`'s own members onto `to`, then drop the ones `from` no longer
141
+ * has. `skip` names members that stay as they are.
142
+ * @param {any} to
143
+ * @param {any} from
144
+ * @param {(key: string | symbol) => boolean} skip
145
+ * @param {(key: string | symbol) => boolean} keep members never deleted
146
+ */
147
+ function hotCopy(to, from, skip, keep) {
148
+ for (const key of Reflect.ownKeys(from)) {
149
+ if (skip(key)) continue;
150
+ const d = Object.getOwnPropertyDescriptor(from, key);
151
+ if (d) Object.defineProperty(to, key, d);
152
+ }
153
+ for (const key of Reflect.ownKeys(to)) {
154
+ if (skip(key) || keep(key) || Object.prototype.hasOwnProperty.call(from, key)) continue;
155
+ delete to[key];
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Install the dev hot client on `g` (normally `globalThis`). Idempotent.
161
+ * @param {any} g
162
+ */
163
+ export function installHotClient(g) {
164
+ const KEY = Symbol.for('webjs.hot');
165
+ if (g[KEY]) return g[KEY];
166
+ /** @type {WeakMap<object, any>} */
167
+ const canonical = new WeakMap();
168
+ /** @type {Map<string, any>} */
169
+ const byText = new Map();
170
+ /** @type {string[] | null} */
171
+ let swapped = null;
172
+
173
+ /** @param {any} arr @param {number} kind */
174
+ function strings(arr, kind) {
175
+ const hit = canonical.get(arr);
176
+ if (hit) return hit;
177
+ const key = kind + '\u0000' + Array.prototype.join.call(arr, '\u0001');
178
+ let c = byText.get(key);
179
+ if (!c) byText.set(key, (c = arr));
180
+ canonical.set(arr, c);
181
+ return c;
182
+ }
183
+
184
+ /** @param {string} tag @param {any} Old @param {any} New */
185
+ function replace(tag, Old, New) {
186
+ const { why, depth } = hotCheck(Old, New);
187
+ if (why) throw new Error('[webjs] hot swap of <' + tag + '> needs a reload: ' + why);
188
+ const props = new Set(hotShape(Old).names);
189
+ const oldChain = hotChain(Old);
190
+ const newChain = hotChain(New);
191
+ for (let i = 0; i < depth; i++) {
192
+ hotCopy(oldChain[i].prototype, newChain[i].prototype,
193
+ (k) => k === 'constructor' || props.has(/** @type {string} */ (k)) || HOT_NATIVE_CALLBACKS.includes(/** @type {string} */ (k)),
194
+ () => false);
195
+ hotCopy(oldChain[i], newChain[i],
196
+ (k) => HOT_FIXED_STATICS.includes(/** @type {string} */ (k)),
197
+ // Framework state cached on a class (a symbol, a `_`-prefixed name)
198
+ // is not the author's, so it is never deleted.
199
+ (k) => typeof k === 'symbol' || k.startsWith('_'));
200
+ }
201
+ // A class that memoizes its finalized styles (Lit's `elementStyles`) keeps
202
+ // the old ones in a cache; an own value shadows it with the new styles.
203
+ if (typeof Old.finalizeStyles === 'function') {
204
+ Object.defineProperty(Old, 'elementStyles', { value: Old.finalizeStyles(Old.styles), configurable: true, writable: true });
205
+ }
206
+ const doc = g.document;
207
+ if (doc) {
208
+ for (const el of hotInstances(doc, Old)) {
209
+ const C = /** @type {any} */ (el).constructor;
210
+ if (el.shadowRoot && C.shadow === true && 'adoptedStyleSheets' in el.shadowRoot) {
211
+ const styles = typeof C.finalizeStyles === 'function' ? C.elementStyles : C.styles;
212
+ const list = Array.isArray(styles) ? styles.flat(Infinity) : styles ? [styles] : [];
213
+ el.shadowRoot.adoptedStyleSheets = list.map(hotSheet).filter(Boolean);
214
+ }
215
+ const inst = /** @type {any} */ (el);
216
+ if (typeof inst.requestUpdate === 'function') inst.requestUpdate();
217
+ }
218
+ }
219
+ if (swapped) swapped.push(tag);
220
+ return Old;
221
+ }
222
+
223
+ /**
224
+ * Import the versioned component modules a `component` verdict lists, in
225
+ * order. A module whose define throws (a guard refused the swap) rejects its
226
+ * import, which ends the apply with that reason.
227
+ * @param {string[]} urls
228
+ * @returns {Promise<{ ok: true, tags: string[] } | { ok: false, why: string }>}
229
+ */
230
+ function applyModules(urls) {
231
+ /** @type {string[]} */
232
+ const tags = [];
233
+ swapped = tags;
234
+ let p = Promise.resolve();
235
+ for (const u of urls) p = p.then(() => import(u));
236
+ return p.then(
237
+ () => { swapped = null; return { ok: /** @type {true} */ (true), tags }; },
238
+ (e) => { swapped = null; return { ok: /** @type {false} */ (false), why: String((e && e.message) || e) }; },
239
+ );
240
+ }
241
+
242
+ const api = { strings, replace, applyModules };
243
+ g[KEY] = api;
244
+ return api;
245
+ }
package/src/dev-import.js CHANGED
@@ -22,17 +22,29 @@
22
22
  * a plain path, so on Bun it falls back to the file URL (no fresh import; the
23
23
  * dev supervisor's restart still picks the edit up).
24
24
  *
25
+ * The content query covers the ENTRY only: a module the entry imports is not
26
+ * re-imported through here. On Node the dev resolve hook (`dev/esm-hot-hooks.js`,
27
+ * #1689) versions everything below it, adding `webjs-v=<n>` to an edited module
28
+ * and its importers on top of this query, which is also what carries a
29
+ * dependency's edit to an entry whose own bytes did not change. Bun has no
30
+ * resolve hook, so there the dev source plugins stamp the versions below the
31
+ * entry (`dev/bun-app-source.js`) and this specifier adds the entry's own
32
+ * `webjs-v=<n>` after the content query.
33
+ *
25
34
  * Outside dev the specifier is the plain file URL, exactly as before.
26
35
  */
27
36
  import { pathToFileURL } from 'node:url';
28
37
  import { readFileSync } from 'node:fs';
29
38
  import { createHash } from 'node:crypto';
39
+ import { explainImportError } from './foreign-imports.js';
40
+ import { moduleVersion } from './dev/esm-hot-hooks.js';
30
41
 
31
42
  /**
32
43
  * @param {string} file absolute file path
33
44
  * @param {boolean} dev whether to cache-bust
34
- * @param {{ bun?: boolean, version?: (file: string) => string }} [opts] test
35
- * seams: the runtime (default: detected) and the content version
45
+ * @param {{ bun?: boolean, version?: (file: string) => string, moduleVersion?: (file: string) => number }} [opts]
46
+ * test seams: the runtime (default: detected), the content version, and the
47
+ * Bun module version (#1689)
36
48
  * @returns {string}
37
49
  */
38
50
  export function devImportSpecifier(file, dev, opts = {}) {
@@ -40,7 +52,10 @@ export function devImportSpecifier(file, dev, opts = {}) {
40
52
  if (!dev) return url;
41
53
  const bun = opts.bun ?? Boolean(typeof process !== 'undefined' && process.versions && process.versions.bun);
42
54
  const bust = `?t=${(opts.version ?? contentVersion)(file)}`;
43
- if (bun && !/[?#]/.test(file)) return file + bust;
55
+ if (bun && !/[?#]/.test(file)) {
56
+ const v = (opts.moduleVersion ?? moduleVersion)(file);
57
+ return file + bust + (v ? `&webjs-v=${v}` : '');
58
+ }
44
59
  return url + bust;
45
60
  }
46
61
 
@@ -113,7 +128,9 @@ export async function devImport(file, dev) {
113
128
  // promise instead. A rejection is evicted so the next call re-imports (the
114
129
  // retry semantic a cold import had), and `clearProdImportCache` drops the
115
130
  // whole map on an embedded host's `rebuild()`.
116
- const p = import(base);
131
+ // A load failure caused by a foreign import (`next/*`, `react`, `lit`)
132
+ // is rewritten to name the WebJs replacement (foreign-imports.js).
133
+ const p = import(base).catch((e) => { throw explainImportError(e, file); });
117
134
  PROD_MODULES.set(file, p);
118
135
  p.catch(() => { if (PROD_MODULES.get(file) === p) PROD_MODULES.delete(file); });
119
136
  return p;
@@ -123,6 +140,6 @@ export async function devImport(file, dev) {
123
140
  return await import(spec);
124
141
  } catch (e) {
125
142
  RETRY_KEYS.set(base, `${base}-r${++retrySeq}`);
126
- throw e;
143
+ throw explainImportError(e, file);
127
144
  }
128
145
  }
@@ -88,6 +88,32 @@ export function dismissDevOverlay() {
88
88
  __wjPendingSeq = -1;
89
89
  }
90
90
 
91
+ /**
92
+ * Take the overlay down after a component hot swap only when the swap settled
93
+ * what it describes (#1689). A component swap re-renders nothing on the
94
+ * server, so nothing would re-raise a frame it wrongly dismissed: a `render`
95
+ * error on this page, or a `ts-strip` failure in some other module, would
96
+ * vanish while it still stands. So the overlay goes only for a `rebuild`
97
+ * frame (the rebuild that produced this swap succeeded) or a frame whose
98
+ * `file` is one the swap re-imported or was edited for, `paths` holding those
99
+ * as app-root paths (`/components/x.ts`).
100
+ *
101
+ * @param {string[]} paths
102
+ * @returns {boolean} whether it dismissed
103
+ */
104
+ export function dismissDevOverlayCoveredBy(paths) {
105
+ const f = __wjFrame;
106
+ if (!f) return false;
107
+ let hit = f.kind === 'rebuild';
108
+ const file = String(f.file || '').replace(/\\/g, '/');
109
+ for (let i = 0; !hit && file && i < paths.length; i++) {
110
+ const p = paths[i];
111
+ hit = !!p && file.length >= p.length && file.slice(file.length - p.length) === p;
112
+ }
113
+ if (hit) dismissDevOverlay();
114
+ return hit;
115
+ }
116
+
91
117
  /** Append a styled text row to `parent`. */
92
118
  function __wjRow(parent, css, text) {
93
119
  const d = document.createElement('div');
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The dev reload client's readiness probe, the BROWSER half (#893, #1689).
3
+ * Browser-safe and import-free: `reloadClientJs` (`dev/helpers.js`) inlines it
4
+ * after an `export` strip, so the unit test drives the exact code that ships.
5
+ *
6
+ * Every reload signal waits for `/__webjs/version` to answer before it is
7
+ * applied, so a tab never reloads INTO a server that is still restarting and
8
+ * paints a half-rendered page. The probe used to give up after 100 tries (about
9
+ * 10 seconds) and reload anyway, which turned any restart slower than that (a
10
+ * cold `dev.before` step, a laptop waking up) into the browser's own error page
11
+ * and a dead tab. It now waits for as long as it takes, Next's posture: 100ms
12
+ * between tries for the first 2 seconds, then 1s for 10 seconds, then every 5s,
13
+ * and it says once that it is waiting. It never reloads by itself.
14
+ *
15
+ * One loop runs at a time. A reload signal that arrives while a probe is
16
+ * already waiting joins it instead of starting a second one, so a burst of
17
+ * edits during a restart does not multiply the requests.
18
+ */
19
+
20
+ /**
21
+ * @param {(url: string, init: object) => Promise<{ ok: boolean }>} fetchFn
22
+ * @param {string} url the readiness endpoint
23
+ * @param {{ setTimeout: (fn: () => void, ms: number) => unknown }} [timers]
24
+ * @param {(msg: string) => void} [log]
25
+ * @returns {(cb: () => void) => void} `whenReady(cb)` runs `cb` once the server answers
26
+ */
27
+ export function createReadyProbe(fetchFn, url, timers, log) {
28
+ const t = timers || { setTimeout: function (fn, ms) { return setTimeout(fn, ms); } };
29
+ const say = log || function (msg) { console.info(msg); };
30
+ /** @type {Array<() => void> | null} */
31
+ let waiting = null;
32
+
33
+ /** @param {number} failures */
34
+ function delayAfter(failures) {
35
+ if (failures <= 20) return 100;
36
+ if (failures <= 30) return 1000;
37
+ return 5000;
38
+ }
39
+
40
+ function settle() {
41
+ const cbs = waiting || [];
42
+ waiting = null;
43
+ for (const cb of cbs) {
44
+ try { cb(); } catch (e) { console.error('[webjs] dev reload handler threw', e); }
45
+ }
46
+ }
47
+
48
+ /** @param {number} failures */
49
+ function attempt(failures) {
50
+ let p;
51
+ try { p = fetchFn(url, { cache: 'no-store' }); } catch (_) { p = Promise.reject(_); }
52
+ Promise.resolve(p).then(function (r) {
53
+ if (r && r.ok) settle(); else again(failures + 1);
54
+ }, function () { again(failures + 1); });
55
+ }
56
+
57
+ /** @param {number} failures */
58
+ function again(failures) {
59
+ // Once per loop, when the fast tries run out (about 2 seconds in).
60
+ if (failures === 20) say('[webjs] waiting for the dev server...');
61
+ t.setTimeout(function () { attempt(failures); }, delayAfter(failures));
62
+ }
63
+
64
+ return function whenReady(cb) {
65
+ if (waiting) { waiting.push(cb); return; }
66
+ waiting = [cb];
67
+ attempt(0);
68
+ };
69
+ }
@@ -63,8 +63,8 @@ export const REGENERATE_MANIFEST = '.webjs/regenerate.json';
63
63
  * Read + normalize the `webjs.dev.regenerate` rules from an app's
64
64
  * `package.json`. Defensive: a missing/malformed block yields `[]` (a plain app
65
65
  * runs unchanged). Each rule's `output` is normalized to an appDir-relative path
66
- * with no leading slash so it matches a served `/public/...` path after the
67
- * slash is stripped.
66
+ * with no leading slash (`public/tailwind.css`), the file a request for the
67
+ * root url `/tailwind.css` maps to under `public/`.
68
68
  *
69
69
  * @param {string} appDir
70
70
  * @param {(p: string) => Promise<string>} [read] injectable reader for tests
@@ -6,9 +6,9 @@
6
6
  * intermediate save, and releases it when the build is done. The embed bridge
7
7
  * (`dev-embed-client.js`) owns the switch, the `__webjsEmbedHold` page global,
8
8
  * set from the host's `{ type: 'hold', enabled }` command. The reload client
9
- * routes every reload signal through this module: while held it keeps only the
10
- * STRONGEST verdict seen, and on release it applies that one reload, or nothing
11
- * when nothing changed.
9
+ * routes every reload signal through this module: while held it merges every
10
+ * verdict it sees, and on release it applies that one reload, or nothing when
11
+ * nothing changed.
12
12
  *
13
13
  * Import-free and inlined verbatim (`export` stripped) into the served reload
14
14
  * client by `reloadClientJs` in `dev/helpers.js`, the same shared-source rule as
@@ -19,42 +19,71 @@
19
19
  * Verdict strength, strongest first. Restated rather than imported from
20
20
  * `dev-reload-worker.js` because this file is inlined on its own.
21
21
  */
22
- var HOLD_STRENGTH = ['reload', 'shell', 'page'];
22
+ var HOLD_STRENGTH = ['reload', 'shell', 'page', 'component', 'css'];
23
23
 
24
24
  /**
25
- * The stronger of two verdicts. Anything that is not a known verdict is a full
26
- * `reload`, the fail-safe every other reload path uses.
25
+ * Read one held signal into the verdict object shape. A bare string (a relay
26
+ * older than #1689 posts the name alone) becomes `{ v }`, and anything that
27
+ * is not a known verdict is a full `reload`, the fail-safe every other reload
28
+ * path uses.
27
29
  *
28
- * @param {string | null} a the verdict held so far (null = none)
29
- * @param {unknown} b the incoming verdict
30
- * @returns {string}
30
+ * @param {unknown} raw
31
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
31
32
  */
32
- export function strongerHeldVerdict(a, b) {
33
- var ib = HOLD_STRENGTH.indexOf(/** @type {string} */ (b));
34
- if (ib === -1) ib = 0;
35
- if (a === null) return HOLD_STRENGTH[ib];
36
- var ia = HOLD_STRENGTH.indexOf(a);
37
- if (ia === -1) ia = 0;
38
- return HOLD_STRENGTH[Math.min(ia, ib)];
33
+ function readHeldVerdict(raw) {
34
+ var o = typeof raw === 'string' ? { v: raw } : raw;
35
+ if (!o || typeof o !== 'object' || HOLD_STRENGTH.indexOf(/** @type {any} */ (o).v) === -1) {
36
+ return { v: 'reload', by: '', why: 'unreadable-frame', modules: [], css: false };
37
+ }
38
+ var a = /** @type {any} */ (o);
39
+ return {
40
+ v: a.v,
41
+ by: typeof a.by === 'string' ? a.by : '',
42
+ why: typeof a.why === 'string' ? a.why : '',
43
+ modules: Array.isArray(a.modules) ? a.modules.slice() : [],
44
+ css: a.css === true,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Merge an incoming verdict into the one held so far: the stronger `v` wins
50
+ * with its `by` / `why` (the held one on a tie), `modules` unions and `css`
51
+ * ORs, the same rule as the server's `mergeVerdicts`.
52
+ *
53
+ * @param {{ v: string, by: string, why: string, modules: string[], css: boolean } | null} a held so far (null = none)
54
+ * @param {unknown} b the incoming verdict, an object or a legacy string
55
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
56
+ */
57
+ export function mergeHeldVerdict(a, b) {
58
+ var next = readHeldVerdict(b);
59
+ if (a === null) return next;
60
+ var top = HOLD_STRENGTH.indexOf(a.v) <= HOLD_STRENGTH.indexOf(next.v) ? a : next;
61
+ var modules = a.modules.slice();
62
+ for (var i = 0; i < next.modules.length; i++) {
63
+ if (modules.indexOf(next.modules[i]) === -1) modules.push(next.modules[i]);
64
+ }
65
+ modules.sort();
66
+ return { v: top.v, by: top.by, why: top.why, modules: modules, css: a.css || next.css };
39
67
  }
40
68
 
41
69
  /**
42
70
  * Build the hold gate for one page.
43
71
  *
44
- * @param {(verdict: string) => void} apply applies a reload (the client's own apply)
72
+ * @param {(verdict: { v: string, by: string, why: string, modules: string[], css: boolean }) => void} apply
73
+ * applies a reload (the client's own apply)
45
74
  * @param {() => boolean} isHeld whether the host currently holds reloads
46
- * @returns {{ offer: (verdict: unknown) => boolean, release: () => void, pending: () => string | null }}
75
+ * @returns {{ offer: (verdict: unknown) => boolean, release: () => void, pending: () => ({ v: string, by: string, why: string, modules: string[], css: boolean } | null) }}
47
76
  * `offer` returns true when it kept the signal (held), false when the caller
48
77
  * should apply it now; `release` applies the one pending reload once the hold
49
78
  * is off.
50
79
  */
51
80
  export function createReloadHold(apply, isHeld) {
52
- /** @type {string | null} */
81
+ /** @type {{ v: string, by: string, why: string, modules: string[], css: boolean } | null} */
53
82
  var held = null;
54
83
  return {
55
84
  offer: function (verdict) {
56
85
  if (!isHeld()) return false;
57
- held = strongerHeldVerdict(held, verdict);
86
+ held = mergeHeldVerdict(held, verdict);
58
87
  return true;
59
88
  },
60
89
  release: function () {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Why the dev server reloaded the page, printed AFTER the reload (#1689), the
3
+ * BROWSER half. Browser-safe and import-free: `reloadClientJs` (`dev/helpers.js`)
4
+ * inlines it after an `export` strip, so the unit test drives the exact code
5
+ * that ships.
6
+ *
7
+ * A reload replaces the document, and with it the console of the page that
8
+ * decided to reload, so a reason logged before `location.reload()` is gone by
9
+ * the time anyone looks (Next prints its `REACT_REFRESH_FULL_RELOAD` reason for
10
+ * the same reason). `reloadWithReason` stores `{ why, by }` in
11
+ * `sessionStorage`, which survives a same-tab reload, and the reloaded page's
12
+ * client prints it once with `printPendingReason`:
13
+ * `[webjs] full reload: <why> (<by>)`. Where storage is unavailable (a
14
+ * sandboxed frame, storage disabled) the reason is logged before the reload
15
+ * instead, which is the best that can be done.
16
+ */
17
+
18
+ const RELOAD_REASON_KEY = 'webjs:dev-reload-reason';
19
+
20
+ /**
21
+ * @param {{ why?: string, by?: string }} r
22
+ * @returns {string}
23
+ */
24
+ function reloadReasonLine(r) {
25
+ return '[webjs] full reload: ' + (r.why || 'reload') + (r.by ? ' (' + r.by + ')' : '');
26
+ }
27
+
28
+ /**
29
+ * Remember why, then reload.
30
+ * @param {{ v?: string, why?: string, by?: string }} verdict
31
+ * @param {{ reload: () => void }} loc
32
+ * @param {Storage | null | undefined} store
33
+ * @param {(msg: string) => void} log
34
+ */
35
+ export function reloadWithReason(verdict, loc, store, log) {
36
+ const r = { why: (verdict && (verdict.why || verdict.v)) || 'reload', by: (verdict && verdict.by) || '' };
37
+ let kept = false;
38
+ try {
39
+ if (store) { store.setItem(RELOAD_REASON_KEY, JSON.stringify(r)); kept = true; }
40
+ } catch (_) { /* storage disabled or full: log it now instead */ }
41
+ if (!kept) log(reloadReasonLine(r));
42
+ loc.reload();
43
+ }
44
+
45
+ /**
46
+ * Print the reason a previous page stored before it reloaded, once.
47
+ * @param {Storage | null | undefined} store
48
+ * @param {(msg: string) => void} log
49
+ */
50
+ export function printPendingReason(store, log) {
51
+ let raw = null;
52
+ try {
53
+ if (!store) return;
54
+ raw = store.getItem(RELOAD_REASON_KEY);
55
+ if (raw !== null) store.removeItem(RELOAD_REASON_KEY);
56
+ } catch (_) { return; }
57
+ if (!raw) return;
58
+ let r;
59
+ try { r = JSON.parse(raw); } catch (_) { return; }
60
+ if (r && typeof r === 'object') log(reloadReasonLine(r));
61
+ }