@webjsdev/server 0.8.91 → 0.9.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.
- package/index.d.ts +41 -0
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-input.js +208 -0
- package/src/actions.js +17 -2
- package/src/check/helpers.js +85 -0
- package/src/check/rules-components.js +33 -2
- package/src/check/rules.js +5 -0
- package/src/check/runner.js +2 -0
- package/src/component-elision.js +64 -88
- package/src/dev/dev-status.js +122 -0
- package/src/dev/handler.js +131 -1
- package/src/dev/serve.js +18 -8
- package/src/dev/server.js +17 -4
- package/src/dev-regenerate.js +320 -76
- package/src/dev-styles.js +64 -6
- package/src/form-dispatch.js +10 -4
- package/src/ssr/document.js +12 -4
- package/src/ssr/head.js +150 -2
- package/src/ssr/render.js +2 -0
- package/src/ssr/shipped-modules.js +30 -0
- package/src/testing.d.ts +1 -1
- package/src/testing.js +7 -2
- package/webjs-config.schema.json +2 -2
package/index.d.ts
CHANGED
|
@@ -573,6 +573,47 @@ export interface ElisionReport {
|
|
|
573
573
|
*/
|
|
574
574
|
export declare function analyzeAppElision(appDir: string): Promise<ElisionReport>;
|
|
575
575
|
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// dev-regenerate.js (#967, #1672)
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Where a `webjs.dev.regenerate` output stands against the build record in
|
|
582
|
+
* `.webjs/regenerate.json`: `fresh` (built by this rule's command from inputs
|
|
583
|
+
* that have not changed since, and not rewritten since), `stale`, `missing`
|
|
584
|
+
* (no output on disk), or `unrecorded` (an output no recorded build produced,
|
|
585
|
+
* such as one compiled by hand).
|
|
586
|
+
*/
|
|
587
|
+
export type RegenerateState = 'fresh' | 'stale' | 'missing' | 'unrecorded';
|
|
588
|
+
|
|
589
|
+
/** One rule's outcome from `regenerateOutputs`. */
|
|
590
|
+
export type RegenerateResult = { output: string; state: RegenerateState; built: boolean } & (
|
|
591
|
+
| { ok: true }
|
|
592
|
+
| { ok: false; reason: 'failed' | 'no-output' }
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Build every `webjs.dev.regenerate` output that is not fresh, one at a time,
|
|
597
|
+
* and record what each was built from (#1672). The engine behind
|
|
598
|
+
* `webjs regenerate`, which an app runs in `dev.before` / `start.before` so a
|
|
599
|
+
* build outside the dev server records freshness the way a dev request does.
|
|
600
|
+
* A rule whose command fails, or exits 0 without writing its `output`, keeps
|
|
601
|
+
* its previous output and is reported `ok: false`.
|
|
602
|
+
*/
|
|
603
|
+
export declare function regenerateOutputs(
|
|
604
|
+
appDir: string,
|
|
605
|
+
opts?: { force?: boolean; warn?: (message: string) => void },
|
|
606
|
+
): Promise<RegenerateResult[]>;
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Every `webjs.dev.regenerate` output's state against the build record,
|
|
610
|
+
* building nothing. What `webjs doctor` reads, so it and the dev server share
|
|
611
|
+
* one definition of fresh.
|
|
612
|
+
*/
|
|
613
|
+
export declare function regenerateStatus(
|
|
614
|
+
appDir: string,
|
|
615
|
+
): Promise<Array<{ output: string; command: string; state: RegenerateState }>>;
|
|
616
|
+
|
|
576
617
|
// ---------------------------------------------------------------------------
|
|
577
618
|
// elision-differential.js (#1308)
|
|
578
619
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -44,6 +44,7 @@ export {
|
|
|
44
44
|
export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
|
|
45
45
|
export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
|
|
46
46
|
export { analyzeAppElision } from './src/elision-report.js';
|
|
47
|
+
export { regenerateOutputs, regenerateStatus } from './src/dev-regenerate.js';
|
|
47
48
|
export { maskJsSet, staticPageRoutes } from './src/elision-differential.js';
|
|
48
49
|
export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
|
|
49
50
|
export { defaultLogger } from './src/logger.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"amaro": "^1.1.10"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@webjsdev/core": "^0.
|
|
58
|
+
"@webjsdev/core": "^0.8.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@webjsdev/core": "^0.
|
|
61
|
+
"@webjsdev/core": "^0.8.0"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The input shape a server action receives from a form submission.
|
|
3
|
+
*
|
|
4
|
+
* A `'use server'` action is reachable three ways, and before this module each
|
|
5
|
+
* handed it a different first argument: the deserialized arguments over RPC, a
|
|
6
|
+
* merged plain object through the `route()` adapter, and a raw `FormData` from
|
|
7
|
+
* a bound `<form action=${fn}>`. A validator written for the RPC shape
|
|
8
|
+
* (`input.title`) read `undefined` off the `FormData` and answered every form
|
|
9
|
+
* submission with a 422, while `webjs check` and typecheck both passed.
|
|
10
|
+
*
|
|
11
|
+
* `formInput` returns a `FormInput`: a real `FormData` (so `instanceof
|
|
12
|
+
* FormData`, the readers, the writers and `fetch(url, { body: input })` all
|
|
13
|
+
* behave as they always did) that ALSO carries every submitted field as an own
|
|
14
|
+
* enumerable property, so `input.title`, destructuring, spreading and a schema
|
|
15
|
+
* library's `parse(input)` read it like the plain object RPC delivers. Next
|
|
16
|
+
* and TanStack Start hand an action a `FormData`; this keeps that and adds the
|
|
17
|
+
* property view the RPC shape needs, so neither set of habits breaks.
|
|
18
|
+
*
|
|
19
|
+
* - A property holds the field's LAST value (the `Object.fromEntries`
|
|
20
|
+
* semantics). A name ending in `[]` collects every value into an array
|
|
21
|
+
* under the name without the brackets, the convention Rails' parameter
|
|
22
|
+
* builder uses for a checkbox group. A `File` stays a real `File`.
|
|
23
|
+
* - `get`, `getAll` and `has` also accept the bracket-less name, so
|
|
24
|
+
* `input.getAll('tags')` reads a `tags[]` group and agrees with
|
|
25
|
+
* `input.tags`. Iteration (`entries`, `keys`, `values`, `forEach`,
|
|
26
|
+
* `Object.fromEntries(input)`) reports names as SUBMITTED, brackets
|
|
27
|
+
* included, like any `FormData`.
|
|
28
|
+
* - A name submitted both plain and with `[]` (`x=1&x[]=2`) is one array
|
|
29
|
+
* group, so nothing is dropped. Rails rejects that shape as a parameter
|
|
30
|
+
* type error, so dev warns once about it.
|
|
31
|
+
* - The writers (`append`, `set`, `delete`) keep the property in step.
|
|
32
|
+
*
|
|
33
|
+
* Fields are defined with `Object.defineProperty`, never assigned, so a field
|
|
34
|
+
* named `__proto__` becomes an own data property instead of reaching the
|
|
35
|
+
* prototype setter. A field that shares a method's name wins as data on the
|
|
36
|
+
* instance: the data is what the author named.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Names already warned about, so a repeated name warns once per process. */
|
|
42
|
+
const warned = new Set();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A `FormData` whose submitted fields are also own enumerable properties.
|
|
46
|
+
* Built only by `formInput`.
|
|
47
|
+
*/
|
|
48
|
+
class FormInput extends FormData {
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} name
|
|
51
|
+
* @returns {FormDataEntryValue | null}
|
|
52
|
+
*/
|
|
53
|
+
get(name) {
|
|
54
|
+
if (super.has(name)) return super.get(name);
|
|
55
|
+
const alias = arrayName(name);
|
|
56
|
+
return alias && super.has(alias) ? super.get(alias) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} name
|
|
61
|
+
* @returns {FormDataEntryValue[]}
|
|
62
|
+
*/
|
|
63
|
+
getAll(name) {
|
|
64
|
+
const alias = arrayName(name);
|
|
65
|
+
return alias ? [...super.getAll(name), ...super.getAll(alias)] : super.getAll(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} name */
|
|
69
|
+
has(name) {
|
|
70
|
+
const alias = arrayName(name);
|
|
71
|
+
return super.has(name) || (!!alias && super.has(alias));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} name
|
|
76
|
+
* @param {string | Blob} value
|
|
77
|
+
* @param {string} [filename]
|
|
78
|
+
*/
|
|
79
|
+
append(name, value, filename) {
|
|
80
|
+
if (filename === undefined) super.append(name, /** @type {any} */ (value));
|
|
81
|
+
else super.append(name, /** @type {Blob} */ (value), filename);
|
|
82
|
+
syncField(this, name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} name
|
|
87
|
+
* @param {string | Blob} value
|
|
88
|
+
* @param {string} [filename]
|
|
89
|
+
*/
|
|
90
|
+
set(name, value, filename) {
|
|
91
|
+
if (filename === undefined) super.set(name, /** @type {any} */ (value));
|
|
92
|
+
else super.set(name, /** @type {Blob} */ (value), filename);
|
|
93
|
+
syncField(this, name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} name */
|
|
97
|
+
delete(name) {
|
|
98
|
+
super.delete(name);
|
|
99
|
+
syncField(this, name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Convert a submitted `FormData` into the action input.
|
|
105
|
+
*
|
|
106
|
+
* @param {FormData} formData
|
|
107
|
+
* @param {{ warn?: (message: string) => void, label?: string, omit?: string }} [opts]
|
|
108
|
+
* `warn` is passed in dev only: it reports a plain name submitted more than
|
|
109
|
+
* once (its property keeps only the last value) and a name submitted under
|
|
110
|
+
* both spellings. `label` names the action in that message and keys the
|
|
111
|
+
* once-per-process dedupe. `omit` is a field to leave out (the framework's
|
|
112
|
+
* own `__webjs_action` identity).
|
|
113
|
+
* @returns {FormData & Record<string, FormDataEntryValue | FormDataEntryValue[]>}
|
|
114
|
+
*/
|
|
115
|
+
export function formInput(formData, opts = {}) {
|
|
116
|
+
const input = new FormInput();
|
|
117
|
+
/** @type {Map<string, number>} */
|
|
118
|
+
const counts = new Map();
|
|
119
|
+
/** @type {Set<string>} */
|
|
120
|
+
const plain = new Set();
|
|
121
|
+
for (const [name, value] of formData.entries()) {
|
|
122
|
+
if (name === opts.omit) continue;
|
|
123
|
+
FormData.prototype.append.call(input, name, value);
|
|
124
|
+
const key = isArrayName(name) ? name.slice(0, -2) : name;
|
|
125
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
126
|
+
if (!isArrayName(name)) plain.add(name);
|
|
127
|
+
}
|
|
128
|
+
for (const key of counts.keys()) syncField(input, key);
|
|
129
|
+
|
|
130
|
+
if (typeof opts.warn === 'function') {
|
|
131
|
+
for (const [key, n] of counts) {
|
|
132
|
+
const grouped = FormData.prototype.has.call(input, key + '[]');
|
|
133
|
+
let message = null;
|
|
134
|
+
if (grouped && plain.has(key)) {
|
|
135
|
+
message = `[webjs] the form field "${key}" was submitted both as "${key}" and as "${key}[]"${opts.label ? ` to ${opts.label}` : ''}, `
|
|
136
|
+
+ `so input.${key} holds every value as one array. Give the controls one spelling, "${key}[]" for a group.`;
|
|
137
|
+
} else if (!grouped && n > 1) {
|
|
138
|
+
message = `[webjs] the form field "${key}" was submitted ${n} times${opts.label ? ` to ${opts.label}` : ''}, `
|
|
139
|
+
+ `so input.${key} holds only the last value. Name the controls "${key}[]" to receive an array `
|
|
140
|
+
+ `as input.${key}, or read every value with input.getAll('${key}').`;
|
|
141
|
+
}
|
|
142
|
+
if (!message) continue;
|
|
143
|
+
const warnKey = `${opts.label || ''}\u0000${key}`;
|
|
144
|
+
if (warned.has(warnKey)) continue;
|
|
145
|
+
warned.add(warnKey);
|
|
146
|
+
opts.warn(message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return /** @type {any} */ (input);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True for a real `FormData` that `formInput` has not converted yet.
|
|
155
|
+
* @param {unknown} value
|
|
156
|
+
*/
|
|
157
|
+
export function isFormData(value) {
|
|
158
|
+
return typeof FormData !== 'undefined' && value instanceof FormData && !(value instanceof FormInput);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Recompute the property for the field a submitted name belongs to, from the
|
|
163
|
+
* entries currently in the form: an array when the name ever arrived as `[]`,
|
|
164
|
+
* else the last value, else no property at all.
|
|
165
|
+
* @param {FormInput} input
|
|
166
|
+
* @param {string} name a submitted name, with or without `[]`
|
|
167
|
+
*/
|
|
168
|
+
function syncField(input, name) {
|
|
169
|
+
const key = isArrayName(name) ? name.slice(0, -2) : name;
|
|
170
|
+
const own = FormData.prototype.getAll.call(input, key);
|
|
171
|
+
if (FormData.prototype.has.call(input, key + '[]')) {
|
|
172
|
+
// Plain and bracketed entries in submission order, so a mixed form keeps
|
|
173
|
+
// every value in the order the browser sent it.
|
|
174
|
+
/** @type {FormDataEntryValue[]} */
|
|
175
|
+
const values = [];
|
|
176
|
+
for (const [n, v] of FormData.prototype.entries.call(input)) if (n === key || n === key + '[]') values.push(v);
|
|
177
|
+
define(input, key, values);
|
|
178
|
+
} else if (own.length) {
|
|
179
|
+
define(input, key, own[own.length - 1]);
|
|
180
|
+
} else if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
181
|
+
delete /** @type {any} */ (input)[key];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True for a submitted name in the array form (`tags[]`).
|
|
187
|
+
* @param {string} name
|
|
188
|
+
*/
|
|
189
|
+
function isArrayName(name) {
|
|
190
|
+
return name.length > 2 && name.endsWith('[]');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The `[]` spelling of a bracket-less name, or null for a name already in it.
|
|
195
|
+
* @param {string} name
|
|
196
|
+
*/
|
|
197
|
+
function arrayName(name) {
|
|
198
|
+
return isArrayName(name) ? null : name + '[]';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {object} target
|
|
203
|
+
* @param {string} key
|
|
204
|
+
* @param {unknown} value
|
|
205
|
+
*/
|
|
206
|
+
function define(target, key, value) {
|
|
207
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
208
|
+
}
|
package/src/actions.js
CHANGED
|
@@ -19,8 +19,9 @@ import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
|
19
19
|
import { getBodyLimits } from './context.js';
|
|
20
20
|
import { basePath } from './importmap.js';
|
|
21
21
|
import { withBasePath } from './base-path.js';
|
|
22
|
-
import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
|
|
22
|
+
import { FORM_ACTION_ID_KEY, FORM_ACTION_FIELD } from '@webjsdev/core';
|
|
23
23
|
import { devImport } from './dev-import.js';
|
|
24
|
+
import { formInput, isFormData } from './action-input.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* The JSON / RPC body cap in effect for the current request: the per-request
|
|
@@ -447,8 +448,11 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
447
448
|
* it so a throwing sink can never affect the response.
|
|
448
449
|
* @param {string[]} [allowedOrigins] cross-site origins the CSRF check allows
|
|
449
450
|
* (the app's `webjs.allowedOrigins`), for reverse-proxy / multi-domain setups.
|
|
451
|
+
* @param {{ warn?: (msg: string) => void }} [logger] where a dev diagnostic
|
|
452
|
+
* goes (the repeated-field warning, #1664), so it lands with the form path's
|
|
453
|
+
* warnings instead of on the console. Falls back to `console.warn`.
|
|
450
454
|
*/
|
|
451
|
-
export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = []) {
|
|
455
|
+
export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = [], logger) {
|
|
452
456
|
const file = idx.hashToFile.get(hash);
|
|
453
457
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
454
458
|
const mod = await loadModule(file, idx.dev);
|
|
@@ -496,6 +500,17 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
496
500
|
} catch {
|
|
497
501
|
return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
|
|
498
502
|
}
|
|
503
|
+
// A FormData first argument (the serializer round-trips one) gets the same
|
|
504
|
+
// shape a bound form submission does, so an action's input never depends on
|
|
505
|
+
// the boundary it was reached through (#1664, `action-input.js`). It stays a
|
|
506
|
+
// real FormData, so a caller that built one keeps every FormData behaviour.
|
|
507
|
+
// A component that intercepts a bound form and passes `new FormData(form)`
|
|
508
|
+
// carries the framework's hidden identity field, which the form path removes
|
|
509
|
+
// before the action runs; it is removed here too.
|
|
510
|
+
if (isFormData(args[0])) {
|
|
511
|
+
const warn = logger && logger.warn ? (m) => logger.warn(m) : (m) => console.warn(m);
|
|
512
|
+
args = [formInput(args[0], { omit: FORM_ACTION_FIELD, ...(idx.dev ? { warn, label: fnName } : {}) }), ...args.slice(1)];
|
|
513
|
+
}
|
|
499
514
|
// Input validation (#245): a validator declared via the `validate` config
|
|
500
515
|
// export (#488) runs SERVER-SIDE before the body on this RPC path. The RPC
|
|
501
516
|
// stub sends an args array; an action conventionally takes one input object,
|
package/src/check/helpers.js
CHANGED
|
@@ -179,6 +179,91 @@ const HTMLELEMENT_MEMBERS = [
|
|
|
179
179
|
'focus', 'blur', 'scrollIntoView',
|
|
180
180
|
];
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Writes to a signal field of a WebComponent class body: any `this.<name>`
|
|
184
|
+
* assignment (plain, compound, `++` / `--`) where `<name>` is a field the same
|
|
185
|
+
* class creates with `signal(` / `computed(`, either as a class-field
|
|
186
|
+
* initializer (`readonly #open = signal(false)`) or in the constructor
|
|
187
|
+
* (`this.open = signal(false)`). That creation is the one write allowed.
|
|
188
|
+
* Assigning to the field replaces the signal object: the component stays
|
|
189
|
+
* subscribed to the old one, so nothing re-renders, and the next `.get()`
|
|
190
|
+
* throws. Expects the redacted body (strings and template text blanked,
|
|
191
|
+
* holes kept).
|
|
192
|
+
*
|
|
193
|
+
* @param {string} body
|
|
194
|
+
* @returns {string[]} the offending field names, each once, `#` kept
|
|
195
|
+
*/
|
|
196
|
+
export function findSignalFieldWrites(body) {
|
|
197
|
+
const MAKE = String.raw`(?:signal|computed)\s*(?:<[^()]*>)?\s*\(`;
|
|
198
|
+
// Brace depth at each index, so a field initializer counts only at the
|
|
199
|
+
// class body's own level.
|
|
200
|
+
const depth = new Int32Array(body.length + 1);
|
|
201
|
+
for (let i = 0, d = 0; i < body.length; i++) {
|
|
202
|
+
if (body[i] === '{') d++;
|
|
203
|
+
else if (body[i] === '}') d--;
|
|
204
|
+
depth[i + 1] = d;
|
|
205
|
+
}
|
|
206
|
+
const fields = new Set();
|
|
207
|
+
// Find each class-level `= signal(` / `= computed(`, then read the field
|
|
208
|
+
// name from the start of its member, so a type annotation of any shape
|
|
209
|
+
// (`Signal<{ open: boolean }>`, `Signal<() => void>`, one spanning lines)
|
|
210
|
+
// sits in between.
|
|
211
|
+
const make = new RegExp(String.raw`(?<![=!<>])=(?!=)\s*` + MAKE, 'g');
|
|
212
|
+
const head = /^\s*((?:(?:public|private|protected|readonly|static|declare|override)\s+)*)(#?[A-Za-z_$][\w$]*)/;
|
|
213
|
+
let m;
|
|
214
|
+
while ((m = make.exec(body))) {
|
|
215
|
+
if (depth[m.index] !== 0) continue;
|
|
216
|
+
const h = head.exec(body.slice(memberStart(body, m.index), m.index));
|
|
217
|
+
if (h && !/\bstatic\s/.test(h[1])) fields.add(h[2]);
|
|
218
|
+
}
|
|
219
|
+
const ctor = methodBodyOf(body, 'constructor');
|
|
220
|
+
const made = new RegExp(String.raw`this\.(#?[A-Za-z_$][\w$]*)\s*=\s*` + MAKE, 'g');
|
|
221
|
+
const created = new Set();
|
|
222
|
+
while ((m = made.exec(ctor))) { fields.add(m[1]); created.add(m.index); }
|
|
223
|
+
if (!fields.size) return [];
|
|
224
|
+
const bad = new Set();
|
|
225
|
+
const write = /this\.(#?[A-Za-z_$][\w$]*)\s*(?:(\+\+|--)|(?:[-+*\/%]|\*\*|\|\||&&|\?\?|<<|>>>?|[&|^])?=(?![=>]))|(?:\+\+|--)\s*this\.(#?[A-Za-z_$][\w$]*)/g;
|
|
226
|
+
const ctorStart = ctor ? body.indexOf(ctor) : -1;
|
|
227
|
+
while ((m = write.exec(body))) {
|
|
228
|
+
const name = m[1] || m[3];
|
|
229
|
+
if (!fields.has(name)) continue;
|
|
230
|
+
// The creation in the constructor is the one allowed write.
|
|
231
|
+
if (ctorStart !== -1 && m.index >= ctorStart && m.index < ctorStart + ctor.length && created.has(m.index - ctorStart)) continue;
|
|
232
|
+
bad.add(name);
|
|
233
|
+
}
|
|
234
|
+
return [...bad];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Index where the class member holding `end` starts: just past the previous
|
|
239
|
+
* `;`, line break, or method body, skipping any of those nested inside
|
|
240
|
+
* brackets (a type annotation can hold all three).
|
|
241
|
+
* @param {string} body
|
|
242
|
+
* @param {number} end
|
|
243
|
+
* @returns {number}
|
|
244
|
+
*/
|
|
245
|
+
function memberStart(body, end) {
|
|
246
|
+
let nest = 0;
|
|
247
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
248
|
+
const c = body[i];
|
|
249
|
+
if (c === '>' && body[i - 1] === '=') { i--; continue; }
|
|
250
|
+
if (c === ')' || c === ']' || c === '}' || c === '>') {
|
|
251
|
+
// A method body ends the previous member: `m() { ... } x = signal(0)`.
|
|
252
|
+
if (c === '}' && nest === 0) {
|
|
253
|
+
let j = i - 1;
|
|
254
|
+
for (let d = 1; j >= 0 && d; j--) d += body[j] === '}' ? 1 : body[j] === '{' ? -1 : 0;
|
|
255
|
+
if (/\)\s*$/.test(body.slice(0, j + 1))) return i + 1;
|
|
256
|
+
}
|
|
257
|
+
nest++;
|
|
258
|
+
} else if (c === '(' || c === '[' || c === '{' || c === '<') {
|
|
259
|
+
nest--;
|
|
260
|
+
} else if (nest === 0 && (c === ';' || c === '\n')) {
|
|
261
|
+
return i + 1;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
182
267
|
/**
|
|
183
268
|
* Extract the body text of a named method from a (redacted) class body, or
|
|
184
269
|
* '' if absent. Handles `async`, a TS return-type annotation, and params.
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
extractWebComponentClassBodies, matchClosingBrace, parsePropEntries,
|
|
11
11
|
} from '../js-scan.js';
|
|
12
12
|
import {
|
|
13
|
-
isComponentFile, arrayPropUsesObject, findFieldInitializers,
|
|
14
|
-
findBrowserMemberUses,
|
|
13
|
+
isComponentFile, arrayPropUsesObject, findFieldInitializers,
|
|
14
|
+
findSignalFieldWrites, methodBodyOf, findBrowserMemberUses,
|
|
15
15
|
} from './helpers.js';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -88,6 +88,37 @@ export function checkNoStaticProperties(files, violations) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Rule: `no-signal-field-assignment`.
|
|
93
|
+
*
|
|
94
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
95
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
export function checkNoSignalFieldAssignment(files, violations) {
|
|
99
|
+
// --- Rule: no-signal-field-assignment ---
|
|
100
|
+
// A signal field is written through `.set()`, never by assignment, which
|
|
101
|
+
// swaps the signal object out from under the component (#1679). TypeScript
|
|
102
|
+
// `readonly` catches this where tsc runs; this rule covers plain `.js` and a
|
|
103
|
+
// forgotten `readonly`. It only sees writes inside the declaring class,
|
|
104
|
+
// which for a `#private` field is every write there can be.
|
|
105
|
+
{
|
|
106
|
+
for (const { rel, scan } of files) {
|
|
107
|
+
if (!/class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/.test(scan)) continue;
|
|
108
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
109
|
+
for (const name of findSignalFieldWrites(body)) {
|
|
110
|
+
violations.push({
|
|
111
|
+
rule: 'no-signal-field-assignment',
|
|
112
|
+
file: rel,
|
|
113
|
+
message: `\`this.${name}\` holds a signal and is assigned to. Assignment replaces the signal object: the component stays subscribed to the old one, nothing re-renders, and the next \`.get()\` throws.`,
|
|
114
|
+
fix: `Write through the signal: \`this.${name}.set(value)\` (or \`this.${name}.set(this.${name}.get() + 1)\` for a counter), and declare the field \`readonly\` in TypeScript so the compiler catches the next one.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
91
122
|
/**
|
|
92
123
|
* Rule: `reactive-props-no-class-field`.
|
|
93
124
|
*
|
package/src/check/rules.js
CHANGED
|
@@ -46,6 +46,11 @@ export const RULES = [
|
|
|
46
46
|
description:
|
|
47
47
|
'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults in the constructor after super().',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: 'no-signal-field-assignment',
|
|
51
|
+
description:
|
|
52
|
+
'A field a WebComponent class creates with `signal(` / `computed(` (a class-field initializer like `readonly #open = signal(false)`, or `this.open = signal(false)` in the constructor) is written through `.set()`, never assigned. `this.open = true` replaces the signal object: the component stays subscribed to the old signal, nothing re-renders, and the next `this.open.get()` throws. Fix: `this.open.set(true)`. Declare signal fields `readonly` in TypeScript so the compiler catches it too; this rule covers plain `.js` and a forgotten `readonly`, for writes inside the declaring class.',
|
|
53
|
+
},
|
|
49
54
|
{
|
|
50
55
|
name: 'reactive-props-no-class-field',
|
|
51
56
|
description:
|
package/src/check/runner.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
checkComponentsHaveRegister,
|
|
9
9
|
checkNoStaticProperties,
|
|
10
|
+
checkNoSignalFieldAssignment,
|
|
10
11
|
checkReactivePropsNoClassField,
|
|
11
12
|
checkArrayPropUsesArrayType,
|
|
12
13
|
checkNoBrowserGlobalsInRender,
|
|
@@ -90,6 +91,7 @@ export async function checkConventions(appDir) {
|
|
|
90
91
|
|
|
91
92
|
checkComponentsHaveRegister(files, violations);
|
|
92
93
|
checkNoStaticProperties(files, violations);
|
|
94
|
+
checkNoSignalFieldAssignment(files, violations);
|
|
93
95
|
checkReactivePropsNoClassField(files, violations);
|
|
94
96
|
checkArrayPropUsesArrayType(files, violations);
|
|
95
97
|
checkNoBrowserGlobalsInRender(files, violations);
|