@starci/eslint-canon-fe 6.0.0 → 6.0.2
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/CHANGELOG.md +15 -0
- package/client-boundary.mjs +4 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/slots.yaml +3 -2
- package/size-and-state-budget.mjs +63 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.0.2 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- **Fix: `use-client-only-at-boundary` refused the providers wrapper.** The route tree's `providers.tsx` (root or `[locale]`,
|
|
6
|
+
role `providers` of `fe.route` in the slot manifest) is the client component a server layout renders around its children to host
|
|
7
|
+
context providers; the directive is accepted there. A providers wrapper kept elsewhere (a layout feature, a module) is still a
|
|
8
|
+
finding, and the message names `providers.tsx` as its home.
|
|
9
|
+
|
|
10
|
+
## 6.0.1 - 2026-09-30
|
|
11
|
+
|
|
12
|
+
- **Fix: `no-hand-rolled-polling` refused a shared clock.** A timer is polling only when what it runs reads data - a call whose
|
|
13
|
+
value is a promise (`fetch`, the transport client, a reader, SWR's `mutate()`), an `await`, or a same-file function that does -
|
|
14
|
+
decided by type, not by name. nivo-fe `useNow` (a one-minute tick behind `useSyncExternalStore`) and a state countdown pass; a
|
|
15
|
+
timer that fetches or revalidates is still refused. The self-scheduling `setTimeout` check follows the enclosing function's
|
|
16
|
+
binding, not its spelling. The FE convention names no shared clock owner, so no clock rule is added.
|
|
17
|
+
|
|
3
18
|
## 6.0.0 - 2026-09-30
|
|
4
19
|
|
|
5
20
|
- **Census fixes (starci-next-fe main, lane F0):** `one-outcome-union` treats a `kind` union as a result union only with an `ok`
|
package/client-boundary.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import { classifyImport, fileOf, isProductSource, isSpecFile, kindOfFile, roleOf
|
|
|
20
20
|
import { typed } from "./lib/types.mjs"
|
|
21
21
|
|
|
22
22
|
/** The route roles that are client-only by the framework's own rule: `error` and `global-error`. */
|
|
23
|
-
|
|
23
|
+
/** Route-tree roles that are client components by construction: the error boundaries, and the providers wrapper a layout renders. */
|
|
24
|
+
const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error", "providers"])
|
|
24
25
|
|
|
25
26
|
/** True when the program opens with the `"use client"` directive. */
|
|
26
27
|
const clientDirective = (program) =>
|
|
@@ -58,9 +59,9 @@ export const useClientOnlyAtBoundary = {
|
|
|
58
59
|
schema: [],
|
|
59
60
|
messages: {
|
|
60
61
|
slot:
|
|
61
|
-
"`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`.",
|
|
62
|
+
"`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`; context providers go in the route tree's `providers.tsx`.",
|
|
62
63
|
elsewhere:
|
|
63
|
-
"`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic,
|
|
64
|
+
"`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, on `error.tsx`/`global-error.tsx`, or on the route tree's `providers.tsx` (the context providers a layout renders around its children). Below a client block the file is already client; above one it must stay a server component.",
|
|
64
65
|
},
|
|
65
66
|
},
|
|
66
67
|
create(context) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-fe",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.2",
|
|
4
4
|
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -662,7 +662,7 @@ slots:
|
|
|
662
662
|
presence: optional
|
|
663
663
|
tracked: tracked
|
|
664
664
|
tier: inherit
|
|
665
|
-
requires: [connection.ts] #
|
|
665
|
+
requires: [connection.ts] # the capability's <c>Entities / <c>Migrations arrays (the owner index re-exports the two arrays by name); no CONNECTION alias: the connection is the one the apps register the arrays on
|
|
666
666
|
allows: ["entities/<table>.entity.ts", "migrations/<epochMs13>-<kebab-name>.ts", "<name>.sql.ts", "<name>.rows.ts", connection.ts, "*.spec.ts"]
|
|
667
667
|
forbids: [index.ts, "<name>.repository.ts"]
|
|
668
668
|
tests: unit-beside
|
|
@@ -838,7 +838,8 @@ slots:
|
|
|
838
838
|
not-found.tsx, layout.tsx, # next-intl: a request outside every locale renders the root not-found inside a root layout
|
|
839
839
|
"health/{live,ready}/route.ts", "api/**/route.ts", "_*/**", providers.tsx, globals.css,
|
|
840
840
|
"{icon,favicon,apple-icon,opengraph-image,twitter-image}.*", robots.ts, sitemap.ts, manifest.ts, "**/*.spec.ts", "**/*.spec.tsx"]
|
|
841
|
-
|
|
841
|
+
# providers.tsx (root or [locale]) is the client wrapper the server layout renders around its children to host context providers.
|
|
842
|
+
roles: {page: page.tsx, layout: layout.tsx, template: template.tsx, loading: loading.tsx, error: error.tsx, not-found: not-found.tsx, default: default.tsx, route: route.ts, global-error: global-error.tsx, providers: providers.tsx}
|
|
842
843
|
tests: none
|
|
843
844
|
rules: [FE_ROUTE_FILES_THIN, FE_CLIENT_BOUNDARY, FE_OWNER_REACHABLE]
|
|
844
845
|
- id: fe.source-root-pinned
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* socket), so the loop in a component is always the wrong one.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { functionOf, isFn } from "./lib/bindings.mjs"
|
|
21
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
|
+
import { isPromiseValued } from "./lib/types.mjs"
|
|
22
24
|
import { fileOf, isSpecFile, roleOfFile, slotOfFile } from "./lib/scope.mjs"
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -167,17 +169,55 @@ const isIntervalCall = (node) => {
|
|
|
167
169
|
)
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
/**
|
|
172
|
+
/** The function node a timer runs: the inline callback, or the same-file function a name stands for. */
|
|
173
|
+
const timerCallback = (context, node) => functionOf(context, node.arguments[0])
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* True when running `fn` reads data: a call in its body (outside nested functions) whose value is a promise - `fetch`, the
|
|
177
|
+
* transport client, a reader, SWR's `mutate()`/revalidation, anything awaited - or an `await`. A tick that only reads the time or
|
|
178
|
+
* sets state (a shared clock, a countdown, an animation step) is not a refresh mechanism.
|
|
179
|
+
*/
|
|
180
|
+
const readsData = (context, fn, seen = new Set()) => {
|
|
181
|
+
if (!fn || seen.has(fn)) return false
|
|
182
|
+
seen.add(fn)
|
|
183
|
+
let found = false
|
|
184
|
+
const visit = (current) => {
|
|
185
|
+
if (found || !current || typeof current.type !== "string") return
|
|
186
|
+
if (current !== fn && isFn(current)) return
|
|
187
|
+
if (current.type === "AwaitExpression") { found = true; return }
|
|
188
|
+
if (current.type === "CallExpression") {
|
|
189
|
+
if (isPromiseValued(context, current)) { found = true; return }
|
|
190
|
+
const local = functionOf(context, current.callee)
|
|
191
|
+
if (local && readsData(context, local, seen)) { found = true; return }
|
|
192
|
+
}
|
|
193
|
+
for (const [key, child] of Object.entries(current)) {
|
|
194
|
+
if (key === "parent") continue
|
|
195
|
+
if (Array.isArray(child)) child.forEach(visit)
|
|
196
|
+
else if (child && typeof child.type === "string") visit(child)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
visit(fn.body)
|
|
200
|
+
return found
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* No hand-written polling loop in a component or hook.
|
|
205
|
+
*
|
|
206
|
+
* A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
|
|
207
|
+
* `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. The FE convention names no shared clock
|
|
208
|
+
* owner (knowledge/hfs/README.md section 6: "one refresh mechanism per resource, poll or socket"), so a timer that only ticks time
|
|
209
|
+
* or state - nivo-fe `useNow`, a one-minute clock behind `useSyncExternalStore` - is not a second refresh mechanism.
|
|
210
|
+
*/
|
|
171
211
|
export const noHandRolledPolling = {
|
|
172
212
|
meta: {
|
|
173
213
|
type: "problem",
|
|
174
|
-
docs: { description: "No `setInterval` and no self-scheduling `setTimeout
|
|
214
|
+
docs: { description: "No `setInterval` and no self-scheduling `setTimeout` that reads data: one refresh mechanism per resource." },
|
|
175
215
|
schema: [],
|
|
176
216
|
messages: {
|
|
177
217
|
interval:
|
|
178
|
-
"A `setInterval` loop. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
|
|
218
|
+
"A `setInterval` loop that reads data. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
|
|
179
219
|
timeout:
|
|
180
|
-
"A `setTimeout` that schedules its own next run is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
|
|
220
|
+
"A `setTimeout` that schedules its own next run and reads data is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
|
|
181
221
|
},
|
|
182
222
|
},
|
|
183
223
|
create(context) {
|
|
@@ -185,19 +225,29 @@ export const noHandRolledPolling = {
|
|
|
185
225
|
const source = context.sourceCode || context.getSourceCode()
|
|
186
226
|
return {
|
|
187
227
|
CallExpression(node) {
|
|
188
|
-
if (isIntervalCall(node))
|
|
228
|
+
if (isIntervalCall(node)) {
|
|
229
|
+
if (readsData(context, timerCallback(context, node))) context.report({ node, messageId: "interval" })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
189
232
|
const callee = node.callee
|
|
190
233
|
if (callee.type !== "Identifier" || callee.name !== "setTimeout") return
|
|
191
234
|
const callback = node.arguments[0]
|
|
192
235
|
if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
|
|
193
|
-
// The enclosing
|
|
194
|
-
const owner = source.getAncestors(node).reverse().find((ancestor) =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
236
|
+
// The enclosing function: does the timer's own callback call it again (the same binding, not the same spelling)?
|
|
237
|
+
const owner = source.getAncestors(node).reverse().find((ancestor) => isFn(ancestor))
|
|
238
|
+
if (!owner) return
|
|
239
|
+
let again = false
|
|
240
|
+
const visit = (current) => {
|
|
241
|
+
if (again || !current || typeof current.type !== "string") return
|
|
242
|
+
if (current.type === "CallExpression" && functionOf(context, current.callee) === owner) { again = true; return }
|
|
243
|
+
for (const [key, child] of Object.entries(current)) {
|
|
244
|
+
if (key === "parent") continue
|
|
245
|
+
if (Array.isArray(child)) child.forEach(visit)
|
|
246
|
+
else if (child && typeof child.type === "string") visit(child)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
visit(callback.body)
|
|
250
|
+
if (again && (readsData(context, callback) || readsData(context, owner))) context.report({ node, messageId: "timeout" })
|
|
201
251
|
},
|
|
202
252
|
}
|
|
203
253
|
},
|