@webjsdev/server 0.8.54 → 0.8.56
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/package.json +1 -1
- package/src/check.js +73 -0
- package/src/component-elision.js +21 -20
- package/src/ssr.js +17 -8
package/package.json
CHANGED
package/src/check.js
CHANGED
|
@@ -119,6 +119,11 @@ export const RULES = [
|
|
|
119
119
|
description:
|
|
120
120
|
'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: 'no-shadowed-native-member',
|
|
124
|
+
description:
|
|
125
|
+
'Flags a WebComponent class method whose name collides with a native DOM mutation method that WebJs relies on and INSTRUMENTS on every light-DOM host for the slot API (#1021): append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, replaceChild. A method of the same name is SHADOWED at runtime (the instrumented native method wins), so the component method silently never runs, while TypeScript stays green because a zero/one-arg override is assignable to the native signature. Found dogfooding the stream demo (#248): a component named its button handler `append()`, so clicking Append called the slot-append no-op instead of the handler. Rename the handler to a non-native name (appendRow / prependRow / removeItem). The `render` / lifecycle hooks are MEANT to be overridden and are not flagged; only the native DOM mutation members are.',
|
|
126
|
+
},
|
|
122
127
|
{
|
|
123
128
|
name: 'no-server-import-in-browser-module',
|
|
124
129
|
description:
|
|
@@ -659,6 +664,74 @@ export async function checkConventions(appDir) {
|
|
|
659
664
|
}
|
|
660
665
|
}
|
|
661
666
|
|
|
667
|
+
// --- Rule: no-shadowed-native-member ---
|
|
668
|
+
// A WebComponent method named after a native DOM mutation method WebJs
|
|
669
|
+
// instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
|
|
670
|
+
// runtime (the native/instrumented method wins), so the component method never
|
|
671
|
+
// runs, while TypeScript stays green (a shorter override is assignable). Rename
|
|
672
|
+
// the handler. The lifecycle hooks are meant to be overridden, so only the DOM
|
|
673
|
+
// mutation members are reserved.
|
|
674
|
+
{
|
|
675
|
+
const NATIVE_MEMBERS = [
|
|
676
|
+
'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
|
|
677
|
+
'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
|
|
678
|
+
];
|
|
679
|
+
for (const { rel, scan } of files) {
|
|
680
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
681
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
682
|
+
// The slot interception installs only on LIGHT-DOM hosts (the shadow
|
|
683
|
+
// branch keeps the true native methods), so a shadow component's method
|
|
684
|
+
// is never shadowed. Skip the whole class.
|
|
685
|
+
if (/static\s+shadow\s*=\s*true/.test(body)) continue;
|
|
686
|
+
// Brace-depth map over the (already string/comment-masked) class body,
|
|
687
|
+
// so only TOP-LEVEL class members flag: a same-named object-literal
|
|
688
|
+
// shorthand or function expression nested inside a method body is a
|
|
689
|
+
// different object's property and shadows nothing.
|
|
690
|
+
const depth = new Int32Array(body.length);
|
|
691
|
+
{
|
|
692
|
+
let d = 0;
|
|
693
|
+
for (let i = 0; i < body.length; i++) {
|
|
694
|
+
if (body[i] === '{') { depth[i] = d; d++; }
|
|
695
|
+
else if (body[i] === '}') { d--; depth[i] = d; }
|
|
696
|
+
else depth[i] = d;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
for (const name of NATIVE_MEMBERS) {
|
|
700
|
+
// Two shadowed-definition shapes, both instance-level: the method /
|
|
701
|
+
// accessor form (`remove() {`, `get remove() {`), and the class-field
|
|
702
|
+
// FUNCTION form (`remove = () => {}` / `= function () {}`), which the
|
|
703
|
+
// connect-time interception install overwrites just the same. A
|
|
704
|
+
// `static` member lives on the constructor and shadows nothing.
|
|
705
|
+
const methodRe = new RegExp(
|
|
706
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
|
|
707
|
+
'g',
|
|
708
|
+
);
|
|
709
|
+
const fieldRe = new RegExp(
|
|
710
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
|
|
711
|
+
'g',
|
|
712
|
+
);
|
|
713
|
+
let flagged = false;
|
|
714
|
+
for (const re of [methodRe, fieldRe]) {
|
|
715
|
+
let m;
|
|
716
|
+
while (!flagged && (m = re.exec(body)) !== null) {
|
|
717
|
+
if (m[1]) continue; // static: not shadowed
|
|
718
|
+
const nameIdx = m.index + m[0].indexOf(m[2]);
|
|
719
|
+
if (depth[nameIdx] !== 0) continue; // nested: another object's property
|
|
720
|
+
flagged = true;
|
|
721
|
+
violations.push({
|
|
722
|
+
rule: 'no-shadowed-native-member',
|
|
723
|
+
file: rel,
|
|
724
|
+
message: `Component member \`${name}\` shadows the native DOM method WebJs instruments for the slot API, so it silently never runs (the native method wins) and TypeScript does not catch it.`,
|
|
725
|
+
fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (flagged) break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
662
735
|
// --- Rule: no-redirect-in-api-route ---
|
|
663
736
|
// `redirect()` from `@webjsdev/core` throws a control-flow signal designed
|
|
664
737
|
// for the SSR page renderer. In a `route.ts` API handler it goes uncaught
|
package/src/component-elision.js
CHANGED
|
@@ -118,16 +118,14 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
118
118
|
];
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
* Method calls that only make sense on the client. `addController`
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* which need the client slot runtime to apply. Any of these implies the
|
|
126
|
-
* component is not inert.
|
|
121
|
+
* Method calls that only make sense on the client. `addController` registers a
|
|
122
|
+
* ReactiveController (client lifecycle); `requestUpdate` schedules a re-render.
|
|
123
|
+
* Any of these implies the component is not inert. (The dynamic slot read
|
|
124
|
+
* surface lives in `SLOT_DYNAMIC_RE`, not here.)
|
|
127
125
|
*
|
|
128
126
|
* @type {readonly string[]}
|
|
129
127
|
*/
|
|
130
|
-
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'
|
|
128
|
+
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
131
129
|
|
|
132
130
|
/**
|
|
133
131
|
* Static class fields whose declaration (to a non-`false` value) marks a
|
|
@@ -215,17 +213,21 @@ const EVENT_BINDING_RE = new RegExp(
|
|
|
215
213
|
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
|
|
216
214
|
|
|
217
215
|
/**
|
|
218
|
-
* Narrow dynamic-slot signals (#1015
|
|
219
|
-
*
|
|
220
|
-
* SSR output already carries the placed children,
|
|
221
|
-
* wrapper is byte-identical with or without its JS
|
|
222
|
-
* genuinely needs the client slot runtime is the
|
|
223
|
-
* `slotchange` listener
|
|
224
|
-
*
|
|
225
|
-
* (
|
|
226
|
-
*
|
|
216
|
+
* Narrow dynamic-slot signals (#1015, retained under #1021's native-parity
|
|
217
|
+
* record model). The old blanket rule shipped ANY component that merely
|
|
218
|
+
* RENDERS a `<slot>`, but the SSR output already carries the placed children,
|
|
219
|
+
* so a display-only slotted wrapper is byte-identical with or without its JS
|
|
220
|
+
* and is elidable. What genuinely needs the client slot runtime is the
|
|
221
|
+
* DYNAMIC READ surface: a `slotchange` listener or an `assignedNodes` /
|
|
222
|
+
* `assignedElements` / `assignedSlot` read. Native WRITE liveness
|
|
223
|
+
* (appendChild, slot= flips) is consumer-driven and usually forces the ship
|
|
224
|
+
* through the consumer's own signals (a shipping component that renders the
|
|
225
|
+
* tag, or an observation form); the remaining carve-out, a shipped script
|
|
226
|
+
* reaching an elided host only via document.querySelector, is inert by
|
|
227
|
+
* design with the `static interactive = true` escape hatch (see the
|
|
228
|
+
* slot.js banner).
|
|
227
229
|
*/
|
|
228
|
-
const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b
|
|
230
|
+
const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b/;
|
|
229
231
|
|
|
230
232
|
/** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
|
|
231
233
|
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
@@ -544,10 +546,9 @@ export function analyzeComponentSource(src) {
|
|
|
544
546
|
// ships: the SSR output carries the placed children, and with no
|
|
545
547
|
// observers in the runtime a display-only slotted wrapper is
|
|
546
548
|
// byte-identical with or without its JS. Only the dynamic slot surface
|
|
547
|
-
// (slotchange, the assigned* reads
|
|
548
|
-
// hasSlot) needs the client runtime.
|
|
549
|
+
// (slotchange, the assigned* reads) needs the client runtime.
|
|
549
550
|
if (SLOT_DYNAMIC_RE.test(src)) {
|
|
550
|
-
return { interactive: true, reason: '
|
|
551
|
+
return { interactive: true, reason: 'reads the dynamic slot surface (slotchange / assignedNodes / assignedElements / assignedSlot)' };
|
|
551
552
|
}
|
|
552
553
|
|
|
553
554
|
// Top-level client work the render/lifecycle checks would miss: a
|
package/src/ssr.js
CHANGED
|
@@ -1124,18 +1124,27 @@ function wrapInDocument(body, opts) {
|
|
|
1124
1124
|
*/
|
|
1125
1125
|
function collectHoistedHeadTags(bodyHtml) {
|
|
1126
1126
|
const tags = [];
|
|
1127
|
-
// <script>…</script> and <style>…</style> are paired; <link …>
|
|
1128
|
-
// A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
|
|
1129
|
-
// comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
1127
|
+
// <script>…</script> and <style>…</style> are paired; <link …> and <meta …>
|
|
1128
|
+
// are void. A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
|
|
1129
|
+
// a comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
1130
1130
|
// between a favicon <link> and the stylesheet <link>) does not terminate
|
|
1131
1131
|
// the leading run and strand the stylesheet in <body>, which caused FOUC
|
|
1132
1132
|
// because a <link rel="stylesheet"> in <body> is not reliably
|
|
1133
|
-
// render-blocking (#406).
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1133
|
+
// render-blocking (#406). A <meta …> is treated the same way for the same
|
|
1134
|
+
// reason: a <meta name="color-scheme"> between the theme <script> and the
|
|
1135
|
+
// stylesheet <link> would otherwise terminate the run and strand the
|
|
1136
|
+
// stylesheet AND a <link rel="icon"> in <body> (a favicon in <body> is
|
|
1137
|
+
// ignored by browsers, so the icon silently never renders). The `(?!/?wj:)`
|
|
1138
|
+
// guard exempts client-router markers (<!--wj:children:…-->,
|
|
1139
|
+
// <!--/wj:children-->) so a layout that renders children directly after its
|
|
1140
|
+
// head tags still terminates the run there rather than swallowing the marker.
|
|
1141
|
+
// The void-tag matchers are QUOTE-AWARE ((?:[^>"']|"[^"]*"|'[^']*')*): a `>`
|
|
1142
|
+
// inside a quoted attribute value (a description meta like
|
|
1143
|
+
// content="Guides > API") must not terminate the tag early, which would hoist
|
|
1144
|
+
// a truncated tag, leak the remainder as visible body text, and strand the
|
|
1145
|
+
// stylesheet (the #406 FOUC this hoist exists to prevent).
|
|
1137
1146
|
const re =
|
|
1138
|
-
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
|
|
1147
|
+
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>|<meta\b(?:[^>"']|"[^"]*"|'[^']*')*>)/i;
|
|
1139
1148
|
let remaining = bodyHtml;
|
|
1140
1149
|
// `body` only advances to just-past the LAST hoisted head tag. Comments
|
|
1141
1150
|
// are scanned through (so they don't terminate the run) but a comment that
|