@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 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.8.91",
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.7.51"
58
+ "@webjsdev/core": "^0.8.0"
59
59
  },
60
60
  "devDependencies": {
61
- "@webjsdev/core": "^0.7.51"
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,
@@ -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, methodBodyOf,
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
  *
@@ -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:
@@ -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);