jamdesk 1.1.201 → 1.1.203
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/dist/__tests__/unit/migrate-risky-expression-warning.test.d.ts +2 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.d.ts.map +1 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.js +47 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.js.map +1 -0
- package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
- package/dist/commands/migrate/convert-mdx.js +5 -1
- package/dist/commands/migrate/convert-mdx.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +7 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/risky-expression-scanner.d.ts.map +1 -1
- package/dist/lib/risky-expression-scanner.js +53 -0
- package/dist/lib/risky-expression-scanner.js.map +1 -1
- package/dist/lib/validate-risky-expressions.d.ts +6 -0
- package/dist/lib/validate-risky-expressions.d.ts.map +1 -1
- package/dist/lib/validate-risky-expressions.js +5 -1
- package/dist/lib/validate-risky-expressions.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/globals.css +57 -0
- package/vendored/components/theme/ThemeToggle.tsx +4 -1
- package/vendored/lib/mdx-inline-components.ts +4 -0
- package/vendored/lib/preprocess-mdx.ts +19 -1
- package/vendored/lib/render-doc-page.tsx +24 -3
- package/vendored/lib/risky-expression-scanner.ts +60 -0
- package/vendored/lib/snippet-compiler-isr.ts +11 -1
- package/vendored/lib/snippet-loader-isr.ts +95 -2
- package/vendored/lib/strip-event-handlers.ts +250 -0
- package/vendored/lib/user-utility-css.ts +250 -0
- package/vendored/workspace-package-lock.json +49 -49
|
@@ -29,6 +29,22 @@ import { visit as visitMdast } from 'unist-util-visit';
|
|
|
29
29
|
import { visit as visitEstree } from 'estree-util-visit';
|
|
30
30
|
import type { Node as EsNode } from 'estree-jsx';
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Event-handler prop test — a DELIBERATE copy of the canonical one in
|
|
34
|
+
* `lib/strip-event-handlers.ts`, kept in sync by
|
|
35
|
+
* `__tests__/lib/event-handler-predicate-parity.test.ts`.
|
|
36
|
+
*
|
|
37
|
+
* Not imported, because this file is vendored standalone into `cli/src/lib/`
|
|
38
|
+
* (see cli/scripts/vendor.js) where no sibling module exists and the CLI's
|
|
39
|
+
* `node16` resolution would reject the extensionless path. Every other
|
|
40
|
+
* build-service file synced there is self-contained for the same reason —
|
|
41
|
+
* adding a second file to that chain would mean touching vendor.js,
|
|
42
|
+
* verify-shared-sync.sh and the drift test for one regex.
|
|
43
|
+
*/
|
|
44
|
+
function isEventHandlerProp(name: string): boolean {
|
|
45
|
+
return /^on[A-Z]/.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** A single risky MDX expression found on a page. */
|
|
33
49
|
export interface RiskyExpressionIssue {
|
|
34
50
|
/** The expression source between the braces, e.g. `"x, y"`. */
|
|
@@ -254,5 +270,49 @@ export function findRiskyExpressions(content: string, pagePath: string): RiskyEx
|
|
|
254
270
|
});
|
|
255
271
|
});
|
|
256
272
|
|
|
273
|
+
// Pass 3: author event handlers (`onClick={…}`, `onSubmit={…}`). These are
|
|
274
|
+
// removed at compile time by `recmaStripEventHandlers` /
|
|
275
|
+
// `babelStripEventHandlers` — a Server Component cannot pass a function across
|
|
276
|
+
// the RSC boundary, and before the strip existed such a prop returned HTTP 500
|
|
277
|
+
// from the serializer (see lib/strip-event-handlers.ts).
|
|
278
|
+
//
|
|
279
|
+
// Unlike pass 2 this needs no scope modelling and has no precision tradeoff:
|
|
280
|
+
// the name alone decides it, and the prop is ALWAYS dropped, so the warning is
|
|
281
|
+
// never a false positive.
|
|
282
|
+
const jsxElementTypes = new Set(['mdxJsxFlowElement', 'mdxJsxTextElement']);
|
|
283
|
+
visitMdast(
|
|
284
|
+
tree as never,
|
|
285
|
+
(node: {
|
|
286
|
+
type: string;
|
|
287
|
+
name?: string | null;
|
|
288
|
+
attributes?: Array<{ type: string; name?: string }>;
|
|
289
|
+
}) => {
|
|
290
|
+
if (!jsxElementTypes.has(node.type) || !node.attributes) return;
|
|
291
|
+
for (const attr of node.attributes) {
|
|
292
|
+
// `mdxJsxExpressionAttribute` is a spread (`{...props}`) — no static
|
|
293
|
+
// name, so it is neither strippable nor warnable.
|
|
294
|
+
if (attr.type !== 'mdxJsxAttribute' || !attr.name) continue;
|
|
295
|
+
if (!isEventHandlerProp(attr.name)) continue;
|
|
296
|
+
const el = node.name ? `<${node.name}>` : 'an element';
|
|
297
|
+
issues.push({
|
|
298
|
+
expression: attr.name,
|
|
299
|
+
undefinedRefs: [],
|
|
300
|
+
// Deliberately does NOT suggest a `'use client'` snippet. Snippets are
|
|
301
|
+
// compiled by the same server pipeline (and `isClientComponent` is
|
|
302
|
+
// computed but never read), so the strip applies there too — the
|
|
303
|
+
// handler would be just as dead, only silently. It DOES still work
|
|
304
|
+
// under `jamdesk dev`, which makes that the worst possible advice:
|
|
305
|
+
// works locally, inert in production.
|
|
306
|
+
message:
|
|
307
|
+
`\`${attr.name}\` on ${el} in \`${pagePath}\` was removed. ` +
|
|
308
|
+
`Documentation pages render on the server, where React cannot pass ` +
|
|
309
|
+
`event handlers, so the handler never ran. Use a link, or one of the ` +
|
|
310
|
+
`built-in interactive components — a custom handler cannot run on a ` +
|
|
311
|
+
`hosted documentation page, including inside a snippet.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
257
317
|
return issues;
|
|
258
318
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { transform } from '@babel/standalone';
|
|
9
|
+
import { babelStripEventHandlers } from './strip-event-handlers';
|
|
9
10
|
import { fetchSnippet } from './r2-content';
|
|
10
11
|
|
|
11
12
|
interface CompiledSnippet {
|
|
@@ -52,7 +53,16 @@ export async function compileSnippetIsr(
|
|
|
52
53
|
// Transpile JSX to JavaScript
|
|
53
54
|
const transpiled = transform(source, {
|
|
54
55
|
presets: ['react', 'typescript'],
|
|
55
|
-
|
|
56
|
+
// babelStripEventHandlers even though NOTHING calls compileSnippetIsr today
|
|
57
|
+
// (only clearSnippetCache/getSnippetCacheSize are imported elsewhere). This
|
|
58
|
+
// file is named as THE ISR snippet compiler, so it is exactly what someone
|
|
59
|
+
// reaches for next — and without the strip it silently reintroduces the
|
|
60
|
+
// HTTP 500 that lib/strip-event-handlers.ts exists to prevent. Two lines of
|
|
61
|
+
// insurance on a dead path beats rediscovering that incident.
|
|
62
|
+
plugins: [
|
|
63
|
+
babelStripEventHandlers,
|
|
64
|
+
['transform-react-jsx', { runtime: 'automatic' }],
|
|
65
|
+
],
|
|
56
66
|
filename: snippetPath,
|
|
57
67
|
});
|
|
58
68
|
|
|
@@ -19,12 +19,15 @@ import { injectPromptSources } from './inject-prompt-source';
|
|
|
19
19
|
import { mdxSecurityOptions } from './mdx-security-options';
|
|
20
20
|
import { remarkSvgNamespaceAttrs } from './remark-svg-namespace-attrs';
|
|
21
21
|
import { remarkStyleStringToObject } from './remark-style-string-to-object';
|
|
22
|
+
import { recmaStripEventHandlers, babelStripEventHandlers } from './strip-event-handlers';
|
|
22
23
|
import {
|
|
23
24
|
mdxEvalGuardPlugin,
|
|
24
25
|
guardPropertyKey,
|
|
25
26
|
KEY_GUARD_NAME,
|
|
26
27
|
STRICT_MODE_PROLOGUE,
|
|
27
28
|
} from './mdx-eval-guard';
|
|
29
|
+
import { extractUtilityCandidates, MAX_UTILITY_CANDIDATES } from './user-utility-css';
|
|
30
|
+
import { logger } from '../shared/logger';
|
|
28
31
|
|
|
29
32
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
33
|
type AnyComponent = React.ComponentType<any>;
|
|
@@ -32,6 +35,14 @@ type AnyComponent = React.ComponentType<any>;
|
|
|
32
35
|
interface CompiledSnippet {
|
|
33
36
|
exports: Record<string, AnyComponent>;
|
|
34
37
|
isClientComponent: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Arbitrary-value Tailwind classes written in this snippet's own source.
|
|
40
|
+
* Carried on the compiled result so it rides the existing snippet cache —
|
|
41
|
+
* the classes an author writes live in the SNIPPET, not in the page that
|
|
42
|
+
* imports it, so a page-source-only scan would miss them entirely.
|
|
43
|
+
* See lib/user-utility-css.ts.
|
|
44
|
+
*/
|
|
45
|
+
utilityCandidates: string[];
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
// In-memory cache for compiled snippet components
|
|
@@ -172,6 +183,12 @@ export function transpileJsx(source: string): string {
|
|
|
172
183
|
// own identifiers, ahead of the JSX transform.
|
|
173
184
|
mdxEvalGuardPlugin,
|
|
174
185
|
onClickToHrefPlugin,
|
|
186
|
+
// The <span onClick={window.open}> → <a href> rewrite above keeps the
|
|
187
|
+
// author's navigation intent and always wins — it visits JSXElement,
|
|
188
|
+
// this visits the child JSXOpeningElement, and Babel reaches the parent
|
|
189
|
+
// first, so array order is not what decides it. Every handler that plugin
|
|
190
|
+
// does not claim is dropped here rather than 500-ing the including page.
|
|
191
|
+
babelStripEventHandlers,
|
|
175
192
|
['transform-react-jsx', { runtime: 'automatic', importSource: 'react' }],
|
|
176
193
|
],
|
|
177
194
|
filename: 'snippet.tsx',
|
|
@@ -282,7 +299,13 @@ async function compilePlainMdxSnippet(
|
|
|
282
299
|
// does on a page, and a snippet crash takes down every page that includes
|
|
283
300
|
// it. The REHYPE pipeline is still deliberately omitted — see the note
|
|
284
301
|
// above about D2 fences.
|
|
285
|
-
|
|
302
|
+
// recmaStripEventHandlers for the same reason the page pipeline runs it:
|
|
303
|
+
// an author `on*` in a snippet is unserializable in RSC and 500s the
|
|
304
|
+
// whole page that includes it (see lib/strip-event-handlers.ts).
|
|
305
|
+
mdxOptions: {
|
|
306
|
+
remarkPlugins: [remarkSvgNamespaceAttrs, remarkStyleStringToObject],
|
|
307
|
+
recmaPlugins: [recmaStripEventHandlers],
|
|
308
|
+
},
|
|
286
309
|
},
|
|
287
310
|
});
|
|
288
311
|
const PlainMdxSnippet: AnyComponent = () => content as React.ReactElement;
|
|
@@ -327,6 +350,7 @@ async function compileSnippet(
|
|
|
327
350
|
const result: CompiledSnippet = {
|
|
328
351
|
exports: { default: component },
|
|
329
352
|
isClientComponent: false,
|
|
353
|
+
utilityCandidates: extractUtilityCandidates(source),
|
|
330
354
|
};
|
|
331
355
|
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
332
356
|
return result;
|
|
@@ -378,7 +402,11 @@ async function compileSnippet(
|
|
|
378
402
|
}
|
|
379
403
|
}
|
|
380
404
|
|
|
381
|
-
const result: CompiledSnippet = {
|
|
405
|
+
const result: CompiledSnippet = {
|
|
406
|
+
exports,
|
|
407
|
+
isClientComponent,
|
|
408
|
+
utilityCandidates: extractUtilityCandidates(source),
|
|
409
|
+
};
|
|
382
410
|
|
|
383
411
|
// Cache the result
|
|
384
412
|
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
@@ -454,6 +482,71 @@ export async function loadSnippetsForIsr(
|
|
|
454
482
|
return components;
|
|
455
483
|
}
|
|
456
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Collect the arbitrary-value Tailwind classes used by a page — its own source
|
|
487
|
+
* plus every snippet it imports.
|
|
488
|
+
*
|
|
489
|
+
* Deliberately a separate pass rather than an extra return value from
|
|
490
|
+
* `loadSnippetsForIsr`: `compileSnippet` is cached per `project:path`, so by the
|
|
491
|
+
* time this runs during a page render every snippet is a cache hit and this
|
|
492
|
+
* costs a map lookup. Threading the candidates back through
|
|
493
|
+
* `loadSnippetsForIsr` would instead have changed a signature that
|
|
494
|
+
* `render-doc-page-parallel-helpers` and its tests both depend on.
|
|
495
|
+
*
|
|
496
|
+
* Never throws: a failure to collect styling candidates must not fail a render.
|
|
497
|
+
*/
|
|
498
|
+
export async function collectPageUtilityCandidates(
|
|
499
|
+
projectSlug: string,
|
|
500
|
+
mdxContent: string,
|
|
501
|
+
builtInComponents: Record<string, AnyComponent> = {},
|
|
502
|
+
// Snippet bodies live in R2, which only exists in ISR. Outside it every
|
|
503
|
+
// fetch here would throw from assertR2Configured and be swallowed below —
|
|
504
|
+
// dead work on the render critical path, and an R2-configured non-ISR
|
|
505
|
+
// environment would pull PRODUCTION snippet content into a local preview.
|
|
506
|
+
// The page's own source is still scanned either way, because the CLI dev
|
|
507
|
+
// workspace keeps project content outside the tree Tailwind scans, so
|
|
508
|
+
// skipping it would make dev render unstyled where prod renders correctly.
|
|
509
|
+
includeSnippets = true
|
|
510
|
+
): Promise<string[]> {
|
|
511
|
+
const candidates = new Set<string>(extractUtilityCandidates(mdxContent));
|
|
512
|
+
|
|
513
|
+
if (includeSnippets) {
|
|
514
|
+
try {
|
|
515
|
+
const imports = extractSnippetImports(mdxContent);
|
|
516
|
+
await Promise.all(
|
|
517
|
+
imports.map(async (imp) => {
|
|
518
|
+
try {
|
|
519
|
+
const compiled = await compileSnippet(
|
|
520
|
+
projectSlug,
|
|
521
|
+
normalizeSnippetPath(imp.path),
|
|
522
|
+
builtInComponents
|
|
523
|
+
);
|
|
524
|
+
for (const c of compiled.utilityCandidates) candidates.add(c);
|
|
525
|
+
} catch {
|
|
526
|
+
// A snippet that fails to compile renders degraded anyway; its
|
|
527
|
+
// styling is not worth failing the page for.
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// Not reachable today: extractSnippetImports and normalizeSnippetPath do
|
|
533
|
+
// not throw, and the Promise.all cannot reject because every element
|
|
534
|
+
// catches. Kept as a render-path guard — but logged, so a future change
|
|
535
|
+
// that does start throwing here is visible rather than silently costing
|
|
536
|
+
// every page its snippet styling.
|
|
537
|
+
logger.warn('[user-utility-css] snippet candidate scan failed', {
|
|
538
|
+
projectSlug,
|
|
539
|
+
error: error instanceof Error ? error.message : String(error),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The per-source cap inside extractUtilityCandidates does NOT bound this
|
|
545
|
+
// union: a page importing 10 snippets could otherwise reach the compiler with
|
|
546
|
+
// 10x the cap and build a cache key tens of KB long. Re-apply it to the merge.
|
|
547
|
+
return [...candidates].sort().slice(0, MAX_UTILITY_CANDIDATES);
|
|
548
|
+
}
|
|
549
|
+
|
|
457
550
|
/**
|
|
458
551
|
* Clear the snippet component cache.
|
|
459
552
|
*/
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { visit } from 'estree-util-visit';
|
|
2
|
+
import type { Program, Property } from 'estree-jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* strip-event-handlers — removes author-written `on*` event-handler props from
|
|
6
|
+
* user MDX/JSX before it is rendered as a React Server Component.
|
|
7
|
+
*
|
|
8
|
+
* WHY this exists, and why it is NOT covered by the existing never-500 layers:
|
|
9
|
+
* a Server Component cannot pass a function across the RSC boundary. React
|
|
10
|
+
* throws `Event handlers cannot be passed to Client Component props` while
|
|
11
|
+
* SERIALIZING the flight payload — after every component has rendered
|
|
12
|
+
* successfully. That makes it invisible to both existing guards:
|
|
13
|
+
*
|
|
14
|
+
* - `MdxRenderBoundary` (components/errors/MdxRenderBoundary.tsx) is a client
|
|
15
|
+
* class boundary. It only catches throws during CLIENT render, so it cannot
|
|
16
|
+
* stop a server-side serialization failure. The response is still HTTP 500.
|
|
17
|
+
* Its `fallback` prop IS serialized alongside `children`, so the browser
|
|
18
|
+
* shows "⚠ This content couldn't be displayed (page content)" while the
|
|
19
|
+
* status is 500 — content degrades, the status does not.
|
|
20
|
+
* - `recmaGuardExpressions` wraps author expressions in a try/catch IIFE, but
|
|
21
|
+
* `onSubmit={() => …}` does not throw when EVALUATED — it returns a function
|
|
22
|
+
* perfectly well. The guard hands that function straight to the serializer.
|
|
23
|
+
*
|
|
24
|
+
* So, exactly as the bare-`{x, y}` class before it, the only RSC-safe fix is to
|
|
25
|
+
* neutralize the construct at COMPILE time. Observed in production on
|
|
26
|
+
* 2026-08-31: one customer page rendering a snippet with `<form onSubmit={…}>`
|
|
27
|
+
* returned 500 for three weeks.
|
|
28
|
+
*
|
|
29
|
+
* WHAT is removed: any prop whose name matches `/^on[A-Z]/` — React's own
|
|
30
|
+
* event-handler naming convention, and the same test React applies when it
|
|
31
|
+
* decides a prop is an event handler. The element and all its other props and
|
|
32
|
+
* children render normally; only the dead handler goes. This loses nothing that
|
|
33
|
+
* ever worked: an author `on*` in server-rendered MDX has never been functional,
|
|
34
|
+
* it has only ever been a 500.
|
|
35
|
+
*
|
|
36
|
+
* Two plugins because user content reaches React down two different pipelines:
|
|
37
|
+
* - `recmaStripEventHandlers` — the MDX compile path (page bodies and
|
|
38
|
+
* plain-markdown snippets), operating on compiled `_jsx(tag, props)` calls.
|
|
39
|
+
* - `babelStripEventHandlers` — the Babel path (`export`-style JSX snippets
|
|
40
|
+
* and inline page components), operating on JSX attributes before the JSX
|
|
41
|
+
* transform runs.
|
|
42
|
+
*
|
|
43
|
+
* RESIDUAL SCOPE — three shapes still reach the serializer and still 500. All
|
|
44
|
+
* three were reproduced against the real flight writer
|
|
45
|
+
* (`react-server-dom-webpack/server.edge` under `--conditions=react-server`):
|
|
46
|
+
*
|
|
47
|
+
* 1. A handler spread from an IDENTIFIER: `<form {...handlers} />`. Not
|
|
48
|
+
* statically visible, so it survives. Note an object-LITERAL spread
|
|
49
|
+
* (`<div {...{onClick: f}} />`) is NOT in this class — the MDX compiler
|
|
50
|
+
* flattens it into the props ObjectExpression, where this plugin sees and
|
|
51
|
+
* removes it like any other property.
|
|
52
|
+
* 2. A computed key: `<div {...{['on' + 'Click']: f}} />` — `propKeyName`
|
|
53
|
+
* declines to guess at computed keys.
|
|
54
|
+
* 3. A function-valued prop whose name is NOT `on[A-Z]` — `render={() => …}`
|
|
55
|
+
* or `children={() => …}`. These fail with a DIFFERENT React error
|
|
56
|
+
* ("Functions cannot be passed directly to Client Components" /
|
|
57
|
+
* "Functions are not valid as a child"), so they are a separate class
|
|
58
|
+
* rather than a hole in this one. Stripping every function-valued prop
|
|
59
|
+
* would close it, but would also strip callbacks that a purely
|
|
60
|
+
* server-rendered inline component legitimately consumes without ever
|
|
61
|
+
* serializing them — so that is deliberately NOT done here.
|
|
62
|
+
*
|
|
63
|
+
* None of the three has been observed in customer content; `on[A-Z]` is the
|
|
64
|
+
* shape authors actually reach for when they paste React into MDX.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* React's own rule for "this prop is an event handler": `on` followed by an
|
|
69
|
+
* uppercase letter. Deliberately NOT a fixed list of known DOM events — a
|
|
70
|
+
* custom `onFoo` on a component prop is just as unserializable as `onClick`,
|
|
71
|
+
* and a list would silently miss every event React adds later.
|
|
72
|
+
*
|
|
73
|
+
* The uppercase requirement is what keeps legitimate props safe: `once`, `only`
|
|
74
|
+
* and `onboarding` are ordinary words, not handlers.
|
|
75
|
+
*/
|
|
76
|
+
const EVENT_HANDLER_PROP = /^on[A-Z]/;
|
|
77
|
+
|
|
78
|
+
export function isEventHandlerProp(name: string): boolean {
|
|
79
|
+
return EVENT_HANDLER_PROP.test(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attributes that only mean something on a `<form>`. When a form is downgraded
|
|
84
|
+
* to a `<div>` (see `neutralizeDeadForm`) these would become invalid DOM
|
|
85
|
+
* attributes and draw React warnings, so they go with it.
|
|
86
|
+
*/
|
|
87
|
+
const FORM_ONLY_PROPS = new Set([
|
|
88
|
+
'method',
|
|
89
|
+
'encType',
|
|
90
|
+
'target',
|
|
91
|
+
'noValidate',
|
|
92
|
+
'acceptCharset',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A `<form>` whose only submit path was an `on*` handler we just stripped, and
|
|
97
|
+
* which has no `action`, is downgraded to a `<div>`.
|
|
98
|
+
*
|
|
99
|
+
* WHY, and why the tag rather than the submit button:
|
|
100
|
+
* with the handler gone the browser falls back to NATIVE submission, and with
|
|
101
|
+
* no `action` that targets the current URL — so a reader who fills the form in
|
|
102
|
+
* is navigated to `?<field>=<whatever they typed>`, the page reloads, their
|
|
103
|
+
* input goes nowhere, and their free text is left in the URL, in history, and
|
|
104
|
+
* in CDN logs. That is strictly worse than the form doing nothing.
|
|
105
|
+
*
|
|
106
|
+
* Observed in production: a customer's feedback snippet POSTed JSON to their
|
|
107
|
+
* own Lambda from `onSubmit`, whose FIRST statement was `e.preventDefault()`.
|
|
108
|
+
* Stripping the handler removed the very thing suppressing the native submit.
|
|
109
|
+
*
|
|
110
|
+
* Neutralizing the submit BUTTON would not close it. HTML implicit submission
|
|
111
|
+
* fires on Enter in a form with a single text field whether or not a submit
|
|
112
|
+
* button exists, so the form element itself has to go. Children render exactly
|
|
113
|
+
* as before; only the submission path disappears.
|
|
114
|
+
*
|
|
115
|
+
* Deliberately narrow: it fires only when a handler was actually removed AND
|
|
116
|
+
* there is no `action`. A form with an `action` still works natively and is
|
|
117
|
+
* left alone, and a form that never had a handler is not this bug.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/** JSX factory callees emitted by the MDX/React compilers. */
|
|
121
|
+
const JSX_CALLEES = new Set(['_jsx', '_jsxs', '_jsxDEV']);
|
|
122
|
+
|
|
123
|
+
/** The identifier/string name of a `_jsx` prop key (`onClick`, `data-v`, …). */
|
|
124
|
+
function propKeyName(prop: Property): string | undefined {
|
|
125
|
+
const key = prop.key;
|
|
126
|
+
// A computed key (`{[expr]: fn}`) has no statically-known name, so it cannot
|
|
127
|
+
// be classified — left in place rather than guessed at.
|
|
128
|
+
if (prop.computed) return undefined;
|
|
129
|
+
if (key.type === 'Identifier') return key.name;
|
|
130
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* recma plugin — drops `on*` properties from every compiled `_jsx(tag, {…})`
|
|
136
|
+
* props object.
|
|
137
|
+
*
|
|
138
|
+
* Runs AFTER `recmaCompoundComponents` (so elements that plugin synthesizes are
|
|
139
|
+
* also cleaned) and BEFORE `recmaGuardExpressions` (no point wrapping an
|
|
140
|
+
* expression that is about to be deleted).
|
|
141
|
+
*/
|
|
142
|
+
export function recmaStripEventHandlers() {
|
|
143
|
+
return (tree: Program) => {
|
|
144
|
+
visit(tree, (node) => {
|
|
145
|
+
if (
|
|
146
|
+
node.type !== 'CallExpression' ||
|
|
147
|
+
node.callee.type !== 'Identifier' ||
|
|
148
|
+
!JSX_CALLEES.has(node.callee.name)
|
|
149
|
+
) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const props = node.arguments[1];
|
|
153
|
+
if (!props || props.type !== 'ObjectExpression') return;
|
|
154
|
+
|
|
155
|
+
const tag = node.arguments[0];
|
|
156
|
+
const isForm =
|
|
157
|
+
tag && tag.type === 'Literal' && tag.value === 'form';
|
|
158
|
+
let strippedHandler = false;
|
|
159
|
+
let hasAction = false;
|
|
160
|
+
|
|
161
|
+
props.properties = props.properties.filter((prop) => {
|
|
162
|
+
// A SpreadElement here is an identifier spread (`{...handlers}`) — an
|
|
163
|
+
// object-literal spread was already flattened into this same properties
|
|
164
|
+
// list by the MDX compiler. Structural, so keep it (RESIDUAL SCOPE 1).
|
|
165
|
+
if (prop.type !== 'Property') return true;
|
|
166
|
+
const name = propKeyName(prop);
|
|
167
|
+
if (!name) return true;
|
|
168
|
+
if (name === 'action') hasAction = true;
|
|
169
|
+
if (!isEventHandlerProp(name)) return true;
|
|
170
|
+
strippedHandler = true;
|
|
171
|
+
return false;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (isForm && strippedHandler && !hasAction) {
|
|
175
|
+
(tag as { value: string; raw?: string }).value = 'div';
|
|
176
|
+
delete (tag as { raw?: string }).raw;
|
|
177
|
+
props.properties = props.properties.filter((prop) => {
|
|
178
|
+
if (prop.type !== 'Property') return true;
|
|
179
|
+
const name = propKeyName(prop);
|
|
180
|
+
return !name || !FORM_ONLY_PROPS.has(name);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Babel plugin — drops `on*` JSX attributes before the JSX transform.
|
|
189
|
+
*
|
|
190
|
+
* Coexists with `onClickToHrefPlugin` in `transpileJsx`, which rewrites
|
|
191
|
+
* `<span onClick={() => window.open(url, '_self')}>` into `<a href>` to preserve
|
|
192
|
+
* the author's navigation intent. That rewrite always wins, and NOT because of
|
|
193
|
+
* plugin array order: it visits `JSXElement` while this visits the child
|
|
194
|
+
* `JSXOpeningElement`, and Babel reaches a parent before its child. Whatever it
|
|
195
|
+
* does not claim is stripped here instead of 500-ing. It is still listed after
|
|
196
|
+
* that plugin, so the guarantee survives if this ever moves to a `JSXElement`
|
|
197
|
+
* visitor.
|
|
198
|
+
*/
|
|
199
|
+
export function babelStripEventHandlers() {
|
|
200
|
+
return {
|
|
201
|
+
name: 'jd-strip-event-handlers',
|
|
202
|
+
visitor: {
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
204
|
+
JSXOpeningElement(path: any) {
|
|
205
|
+
const isForm =
|
|
206
|
+
path.node.name?.type === 'JSXIdentifier' &&
|
|
207
|
+
path.node.name.name === 'form';
|
|
208
|
+
let strippedHandler = false;
|
|
209
|
+
let hasAction = false;
|
|
210
|
+
|
|
211
|
+
path.node.attributes = path.node.attributes.filter(
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
213
|
+
(attr: any) => {
|
|
214
|
+
if (
|
|
215
|
+
attr.type !== 'JSXAttribute' ||
|
|
216
|
+
attr.name?.type !== 'JSXIdentifier'
|
|
217
|
+
) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (attr.name.name === 'action') hasAction = true;
|
|
221
|
+
if (!isEventHandlerProp(attr.name.name)) return true;
|
|
222
|
+
strippedHandler = true;
|
|
223
|
+
return false;
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!isForm || !strippedHandler || hasAction) return;
|
|
228
|
+
|
|
229
|
+
// Downgrade to <div> — see neutralizeDeadForm above. The closing tag
|
|
230
|
+
// is renamed too. Under the `react` preset that lowers JSX to `_jsx`
|
|
231
|
+
// calls this is unobservable (the closing element is discarded), so it
|
|
232
|
+
// reads like dead code — but leaving a mismatched AST would emit
|
|
233
|
+
// `<div>…</form>` for any consumer that preserves JSX, and a test
|
|
234
|
+
// transpiles with JSX preserved specifically to pin it.
|
|
235
|
+
path.node.name.name = 'div';
|
|
236
|
+
const closing = path.parent?.closingElement;
|
|
237
|
+
if (closing?.name?.type === 'JSXIdentifier') closing.name.name = 'div';
|
|
238
|
+
path.node.attributes = path.node.attributes.filter(
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
|
+
(attr: any) =>
|
|
241
|
+
!(
|
|
242
|
+
attr.type === 'JSXAttribute' &&
|
|
243
|
+
attr.name?.type === 'JSXIdentifier' &&
|
|
244
|
+
FORM_ONLY_PROPS.has(attr.name.name)
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|