@barefootjs/jsx 0.5.1 → 0.5.3
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/analyzer-context.d.ts +8 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/combine-client-js.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -65
- package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +26 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +19 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
- package/src/__tests__/child-components-in-map.test.ts +376 -0
- package/src/__tests__/combine-client-js.test.ts +47 -0
- package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
- package/src/__tests__/text-slot-escaping.test.ts +56 -0
- package/src/analyzer-context.ts +59 -13
- package/src/analyzer.ts +8 -0
- package/src/combine-client-js.ts +66 -22
- package/src/expression-parser.ts +16 -1
- package/src/index.ts +2 -0
- package/src/ir-to-client-js/collect-elements.ts +191 -34
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
- package/src/ir-to-client-js/emit-reactive.ts +9 -0
- package/src/ir-to-client-js/html-template.ts +82 -10
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
- package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
- package/src/ir-to-client-js/types.ts +27 -4
- package/src/ir-to-client-js/utils.ts +41 -1
- package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
- package/src/types.ts +6 -0
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* sentinel before destructuring (#951 TDZ-safe).
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { toDomEventName, varSlotId, substituteLoopBindings, DATA_KEY, keyAttrName } from '../../utils'
|
|
35
|
+
import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils'
|
|
36
36
|
import type {
|
|
37
37
|
EventDelegationPlan,
|
|
38
38
|
KeyedItemLookup,
|
|
@@ -187,11 +187,11 @@ function emitStaticIndexLookup(
|
|
|
187
187
|
lookup: StaticIndexItemLookup,
|
|
188
188
|
containerVar: string,
|
|
189
189
|
): void {
|
|
190
|
-
const { arrayExpr, param, mapPreamble,
|
|
190
|
+
const { arrayExpr, param, mapPreamble, offset } = lookup
|
|
191
191
|
ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
|
|
192
192
|
ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
|
|
193
193
|
ls.push(` if (__el.parentElement === ${containerVar}) {`)
|
|
194
|
-
const idxOffset =
|
|
194
|
+
const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
|
|
195
195
|
ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
|
|
196
196
|
ls.push(` const ${param} = ${arrayExpr}[__idx]`)
|
|
197
197
|
if (mapPreamble) ls.push(` ${mapPreamble}`)
|
|
@@ -17,6 +17,15 @@ import { createTemplateAwareStringProtector } from './html-template'
|
|
|
17
17
|
*/
|
|
18
18
|
export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
|
|
19
19
|
const htmlName = toHtmlAttrName(attrName)
|
|
20
|
+
if (attrName === 'dangerouslySetInnerHTML' || htmlName === 'dangerouslySetInnerHTML') {
|
|
21
|
+
// `{ __html }` is not an attribute — it replaces the element's content.
|
|
22
|
+
// Assign `innerHTML` (raw, intentional escape hatch) to mirror the SSR
|
|
23
|
+
// adapters' native `dangerouslySetInnerHTML` handling instead of
|
|
24
|
+
// stringifying the object into a bogus attribute.
|
|
25
|
+
return [
|
|
26
|
+
`{ const __v = ${expression}; ${target}.innerHTML = __v != null && __v.__html != null ? String(__v.__html) : '' }`,
|
|
27
|
+
]
|
|
28
|
+
}
|
|
20
29
|
if (htmlName === 'style') {
|
|
21
30
|
return [
|
|
22
31
|
`{ const __v = styleToCss(${expression}); if (__v != null) ${target}.setAttribute('style', __v); else ${target}.removeAttribute('style') }`,
|
|
@@ -192,11 +192,16 @@ const UNSAFE_TEMPLATE_EXPR = 'undefined'
|
|
|
192
192
|
* @param attr - Attribute metadata (only presenceOrUndefined flag is used)
|
|
193
193
|
*/
|
|
194
194
|
function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined?: boolean): string {
|
|
195
|
+
// `dangerouslySetInnerHTML={{ __html }}` is not an attribute — its value
|
|
196
|
+
// becomes the element's raw innerHTML (emitted as element content). Never
|
|
197
|
+
// serialise the `{ __html }` object into a `dangerouslySetInnerHTML="…"`
|
|
198
|
+
// attribute.
|
|
199
|
+
if (attrName === 'dangerouslySetInnerHTML') return ''
|
|
195
200
|
if (isBooleanAttr(attrName) || presenceOrUndefined) {
|
|
196
201
|
return `\${${valExpr} ? '${attrName}' : ''}`
|
|
197
202
|
}
|
|
198
203
|
if (attrName === 'style') {
|
|
199
|
-
return `\${((v) => v != null ? 'style="' + v + '"' : '')(styleToCss(${valExpr}))}`
|
|
204
|
+
return `\${((v) => v != null ? 'style="' + ${escapeAttrValueExpr('v')} + '"' : '')(styleToCss(${valExpr}))}`
|
|
200
205
|
}
|
|
201
206
|
// `data-key` / `data-key-N` is a reconciliation contract — every loop item
|
|
202
207
|
// must carry one. Emit unconditionally; if the user passes `key={undefined}`
|
|
@@ -205,7 +210,60 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
|
|
|
205
210
|
if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
|
|
206
211
|
return `${attrName}="\${${valExpr}}"`
|
|
207
212
|
}
|
|
208
|
-
return `\${(${valExpr}) != null ? '${attrName}="' +
|
|
213
|
+
return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a runtime expression that HTML-escapes an interpolated attribute
|
|
218
|
+
* value, matching the SSR adapters' attribute escaping (Hono escapes
|
|
219
|
+
* `& " ' < >`) via the `escapeAttr` runtime helper. The client template
|
|
220
|
+
* assembles an HTML string inserted via `innerHTML`, so an unescaped `"`
|
|
221
|
+
* / `<` / `>` in a value — e.g. UnoCSS arbitrary variants like
|
|
222
|
+
* `[class*="size-"]` or `has-[>svg]` — corrupts attribute parsing (and
|
|
223
|
+
* diverges from the SSR-rendered bytes). Escaping at interpolation time
|
|
224
|
+
* is the only correct layer: a post-assembly pass can't tell a delimiter
|
|
225
|
+
* `"` from a value `"`.
|
|
226
|
+
*/
|
|
227
|
+
function escapeAttrValueExpr(valExpr: string): string {
|
|
228
|
+
return `escapeAttr(${valExpr})`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build a runtime expression that HTML-escapes an interpolated **text
|
|
233
|
+
* content** slot, via the `escapeText` runtime helper. Only the
|
|
234
|
+
* `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
|
|
235
|
+
* runtime treats whatever sits between the markers as the slot's text, so
|
|
236
|
+
* a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
|
|
237
|
+
* to parse correctly under `innerHTML` and to match the SSR-rendered
|
|
238
|
+
* bytes. Bare `${...}` interpolations — `{children}` passthrough and
|
|
239
|
+
* `renderChild(...)` output — are pre-rendered HTML and must NOT be
|
|
240
|
+
* escaped, so this is applied only at the four text-marker emit sites.
|
|
241
|
+
* Hono escapes text content with the same set as attribute values
|
|
242
|
+
* (`& " ' < >`), so `escapeText` delegates to the same operation.
|
|
243
|
+
*/
|
|
244
|
+
function escapeTextSlotExpr(innerExpr: string): string {
|
|
245
|
+
return `escapeText(${innerExpr})`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
|
|
250
|
+
* raw innerHTML — the intentional, React-style escape hatch. Returns the
|
|
251
|
+
* raw-content template expression to use *instead of* the element's normal
|
|
252
|
+
* children (emitted UNescaped by design, mirroring the SSR adapters'
|
|
253
|
+
* native handling), or `null` when the element carries no such attribute.
|
|
254
|
+
* The attribute itself is suppressed in `templateAttrExpr`, and the
|
|
255
|
+
* matching reactive update is emitted by `emitAttrUpdate` (assigns
|
|
256
|
+
* `innerHTML`). `toExpr` is the walker's value transform (`wrapExpr` /
|
|
257
|
+
* `transformExpr`) so the `{ __html }` object is lowered the same way an
|
|
258
|
+
* attribute value would be.
|
|
259
|
+
*/
|
|
260
|
+
function dangerouslyHtmlChildren(
|
|
261
|
+
attrs: ReadonlyArray<IRAttribute>,
|
|
262
|
+
toExpr: (v: { expr: string; templateExpr?: string }) => string,
|
|
263
|
+
): string | null {
|
|
264
|
+
const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
|
|
265
|
+
if (!attr || attr.value.kind !== 'expression') return null
|
|
266
|
+
return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
|
|
209
267
|
}
|
|
210
268
|
|
|
211
269
|
/**
|
|
@@ -322,6 +380,12 @@ export interface MergeContext {
|
|
|
322
380
|
function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
|
|
323
381
|
if (ctx.honorClientOnly && a.clientOnly) return false
|
|
324
382
|
if (a.name === 'key') return false
|
|
383
|
+
// `dangerouslySetInnerHTML` is not an attribute — its `{ __html }` value
|
|
384
|
+
// becomes the element's raw innerHTML (emitted as content, set via
|
|
385
|
+
// `innerHTML` in init). Keep it out of the `spreadAttrs({...})` merge so
|
|
386
|
+
// it isn't serialised back into a bogus `dangerouslySetInnerHTML="…"`
|
|
387
|
+
// attribute when the element also carries a spread.
|
|
388
|
+
if (a.name === 'dangerouslySetInnerHTML') return false
|
|
325
389
|
const v = a.value
|
|
326
390
|
if (v.kind === 'jsx-children') return false
|
|
327
391
|
if (v.kind === 'boolean-shorthand') return false
|
|
@@ -476,7 +540,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
476
540
|
|
|
477
541
|
const attrs = attrParts.join(' ')
|
|
478
542
|
const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
|
|
479
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
543
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
|
|
480
544
|
|
|
481
545
|
// Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
|
|
482
546
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
@@ -491,7 +555,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
|
|
|
491
555
|
case 'expression':
|
|
492
556
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
493
557
|
if (node.slotId) {
|
|
494
|
-
|
|
558
|
+
const inner = wrapInterpolation(wrapExpr(node.expr))
|
|
559
|
+
// In branch-slot context `wrapInterpolation` routes the value
|
|
560
|
+
// through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
|
|
561
|
+
// for live `Node` values (spliced back by `insert()`). Escaping
|
|
562
|
+
// would corrupt those markers and drop slotted content (#1694
|
|
563
|
+
// regression). `__bfSlot` owns coercion of its own value, so the
|
|
564
|
+
// text-escape applies only to the non-slot (plain text) form.
|
|
565
|
+
const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
|
|
566
|
+
return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
|
|
495
567
|
}
|
|
496
568
|
return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
|
|
497
569
|
|
|
@@ -656,7 +728,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
656
728
|
}
|
|
657
729
|
|
|
658
730
|
const attrs = attrParts.join(' ')
|
|
659
|
-
const children = node.children.map(recurse).join('')
|
|
731
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(recurse).join('')
|
|
660
732
|
|
|
661
733
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
662
734
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -670,7 +742,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
|
|
|
670
742
|
case 'expression':
|
|
671
743
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
672
744
|
if (node.slotId) {
|
|
673
|
-
return `<!--bf:${node.slotId}-->\${${wrapExpr(node.expr)}}<!--/-->`
|
|
745
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
|
|
674
746
|
}
|
|
675
747
|
return `\${${wrapExpr(node.expr)}}`
|
|
676
748
|
|
|
@@ -1041,7 +1113,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1041
1113
|
}
|
|
1042
1114
|
|
|
1043
1115
|
const attrs = attrParts.join(' ')
|
|
1044
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
1116
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
|
|
1045
1117
|
|
|
1046
1118
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
1047
1119
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -1055,7 +1127,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1055
1127
|
case 'expression':
|
|
1056
1128
|
if (node.expr === 'null' || node.expr === 'undefined') return ''
|
|
1057
1129
|
if (node.slotId) {
|
|
1058
|
-
return `<!--bf:${node.slotId}-->\${${transformExpr(node.expr, node.templateExpr)}}<!--/-->`
|
|
1130
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
|
|
1059
1131
|
}
|
|
1060
1132
|
return `\${${transformExpr(node.expr, node.templateExpr)}}`
|
|
1061
1133
|
|
|
@@ -1417,7 +1489,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1417
1489
|
}
|
|
1418
1490
|
|
|
1419
1491
|
const attrs = attrParts.join(' ')
|
|
1420
|
-
const children = node.children.map(childrenRecurse).join('')
|
|
1492
|
+
const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
|
|
1421
1493
|
|
|
1422
1494
|
if (children || !VOID_ELEMENTS.has(node.tag)) {
|
|
1423
1495
|
return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
|
|
@@ -1440,7 +1512,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1440
1512
|
// an empty placeholder instead.
|
|
1441
1513
|
const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
|
|
1442
1514
|
if (node.slotId) {
|
|
1443
|
-
return `<!--bf:${node.slotId}-->\${${expr}}<!--/-->`
|
|
1515
|
+
return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
|
|
1444
1516
|
}
|
|
1445
1517
|
return `\${${expr}}`
|
|
1446
1518
|
}
|
|
@@ -11,7 +11,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
|
|
|
11
11
|
'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
|
|
12
12
|
'createPortal',
|
|
13
13
|
'provideContext', 'createContext', 'useContext',
|
|
14
|
-
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
|
|
14
|
+
'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
|
|
15
15
|
'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
|
|
16
16
|
] as const
|
|
17
17
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { IRLoopChildComponent } from '../../types'
|
|
17
17
|
import type { NestedLoop, TopLevelLoop } from '../types'
|
|
18
18
|
import type { ClientJsContext } from '../types'
|
|
19
|
-
import { quotePropName, varSlotId, attrValueToString } from '../utils'
|
|
19
|
+
import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils'
|
|
20
20
|
import { irChildrenToJsExpr } from '../html-template'
|
|
21
21
|
import { buildCompSelector } from '../control-flow/shared'
|
|
22
22
|
|
|
@@ -98,7 +98,7 @@ function buildOuterNestedPlan(
|
|
|
98
98
|
arrayExpr: elem.array,
|
|
99
99
|
param: elem.param,
|
|
100
100
|
indexParam,
|
|
101
|
-
offsetExpr:
|
|
101
|
+
offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
|
|
102
102
|
outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
|
|
103
103
|
propsExpr: buildStaticPropsExpr(comp.props),
|
|
104
104
|
}
|
|
@@ -122,16 +122,12 @@ function buildInnerLoopNestedPlan(
|
|
|
122
122
|
outerArrayExpr: elem.array,
|
|
123
123
|
outerParam: elem.param,
|
|
124
124
|
outerIndexParam,
|
|
125
|
-
outerOffsetExpr: elem.
|
|
126
|
-
? `${outerIndexParam} + ${elem.siblingOffset}`
|
|
127
|
-
: outerIndexParam,
|
|
125
|
+
outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
|
|
128
126
|
outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
|
|
129
127
|
innerContainerSlotId: innerLoop.containerSlotId ?? null,
|
|
130
128
|
innerArrayExpr: innerLoop.array,
|
|
131
129
|
innerParam: innerLoop.param,
|
|
132
|
-
innerOffsetExpr: innerLoop.
|
|
133
|
-
? `__innerIdx + ${innerLoop.siblingOffset}`
|
|
134
|
-
: '__innerIdx',
|
|
130
|
+
innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
|
|
135
131
|
innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
|
|
136
132
|
depth: innerLoop.depth,
|
|
137
133
|
comps,
|
|
@@ -75,7 +75,7 @@ export interface OuterNestedInitPlan {
|
|
|
75
75
|
arrayExpr: string
|
|
76
76
|
param: string
|
|
77
77
|
indexParam: string
|
|
78
|
-
/** `indexParam`
|
|
78
|
+
/** `indexParam` plus any sibling-offset terms (`+ 1 + (arr).length`) — already substituted. */
|
|
79
79
|
offsetExpr: string
|
|
80
80
|
/**
|
|
81
81
|
* Outer `.map()` callback preamble locals (#1064), emitted inside the
|
|
@@ -99,7 +99,7 @@ export interface InnerLoopNestedInitPlan {
|
|
|
99
99
|
outerArrayExpr: string
|
|
100
100
|
outerParam: string
|
|
101
101
|
outerIndexParam: string
|
|
102
|
-
/** Outer offset — `outerIndexParam`
|
|
102
|
+
/** Outer offset — `outerIndexParam` plus any sibling-offset terms. */
|
|
103
103
|
outerOffsetExpr: string
|
|
104
104
|
/**
|
|
105
105
|
* Outer `.map()` callback preamble locals, emitted after the
|
|
@@ -115,7 +115,7 @@ export interface InnerLoopNestedInitPlan {
|
|
|
115
115
|
innerContainerSlotId: string | null
|
|
116
116
|
innerArrayExpr: string
|
|
117
117
|
innerParam: string
|
|
118
|
-
/** Inner offset — `__innerIdx`
|
|
118
|
+
/** Inner offset — `__innerIdx` plus any sibling-offset terms. */
|
|
119
119
|
innerOffsetExpr: string
|
|
120
120
|
/**
|
|
121
121
|
* Inner `.map()` callback preamble locals, emitted after the
|
|
@@ -166,6 +166,29 @@ export interface ConditionalBranchReactiveAttr extends AttrMeta {
|
|
|
166
166
|
expression: string
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* How many element children precede a loop's items in its container — the
|
|
171
|
+
* offset applied to `container.children[idx]` so each item resolves to the
|
|
172
|
+
* right element during hydration.
|
|
173
|
+
*
|
|
174
|
+
* Two contributions, kept distinct because they codegen differently:
|
|
175
|
+
* - `staticCount`: siblings of statically-known element count, folded to a
|
|
176
|
+
* compile-time integer (`+ 1`).
|
|
177
|
+
* - `dynamicTerms`: one JS expression per sibling whose element count is
|
|
178
|
+
* only known at runtime — a preceding `.map()` (`(arr).length`) or a
|
|
179
|
+
* preceding conditional (`(cond ? 1 : 0)`) — each added as `+ <term>`.
|
|
180
|
+
*
|
|
181
|
+
* Carried as one value object end-to-end (collect → IR loop → plan → codegen)
|
|
182
|
+
* so a future offset contributor is added in one place, not threaded as a new
|
|
183
|
+
* field through every layer (#1693).
|
|
184
|
+
*/
|
|
185
|
+
export interface LoopOffset {
|
|
186
|
+
/** Folded element count of statically-sized preceding siblings. `0` = none. */
|
|
187
|
+
staticCount: number
|
|
188
|
+
/** Runtime element-count expressions of dynamic preceding siblings; `[]` = none. */
|
|
189
|
+
dynamicTerms: readonly string[]
|
|
190
|
+
}
|
|
191
|
+
|
|
169
192
|
/**
|
|
170
193
|
* Fields shared by every flavour of collected loop (top-level, branch-scoped, nested).
|
|
171
194
|
* The three loop-info variants (`TopLevelLoop`, `BranchLoop`, `NestedLoop`) each extend
|
|
@@ -363,8 +386,8 @@ export interface NestedLoop extends LoopCore {
|
|
|
363
386
|
childComponents?: import('../types').IRLoopChildComponent[]
|
|
364
387
|
/** True when this loop is inside a conditional branch (handled by insert() bindEvents instead) */
|
|
365
388
|
insideConditional?: boolean
|
|
366
|
-
/**
|
|
367
|
-
|
|
389
|
+
/** Offset of this loop's items past its preceding container siblings (#1693). */
|
|
390
|
+
offset?: LoopOffset
|
|
368
391
|
// Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
|
|
369
392
|
// now live on `LoopCore.bindings` — see issue #1244 §B.
|
|
370
393
|
}
|
|
@@ -468,8 +491,8 @@ export interface TopLevelLoop extends LoopCore {
|
|
|
468
491
|
useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
|
|
469
492
|
/** Inner loop metadata for composite element reconciliation (array, param, key, container) */
|
|
470
493
|
innerLoops?: NestedLoop[]
|
|
471
|
-
/**
|
|
472
|
-
|
|
494
|
+
/** Offset of this loop's items past its preceding container siblings (#1693). */
|
|
495
|
+
offset?: LoopOffset
|
|
473
496
|
filterPredicate?: {
|
|
474
497
|
param: string
|
|
475
498
|
raw: string // Original filter predicate expression or block body
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import ts from 'typescript'
|
|
7
7
|
import type { AttrValue, IRTemplatePart, LoopParamBinding, FreeReference, IRNode } from '../types'
|
|
8
|
-
import type { TopLevelLoop, BranchLoop } from './types'
|
|
8
|
+
import type { TopLevelLoop, BranchLoop, LoopOffset } from './types'
|
|
9
9
|
import { buildLoopChainExpr } from '../loop-chain'
|
|
10
10
|
import {
|
|
11
11
|
iterateJsTokens,
|
|
@@ -145,6 +145,46 @@ export function buildChainedArrayExpr(elem: TopLevelLoop | BranchLoop): string {
|
|
|
145
145
|
})
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The single source of truth for what contributes to a loop's child-index
|
|
150
|
+
* offset: the static sibling count (a folded integer) followed by one
|
|
151
|
+
* `(arr).length` term per preceding sibling loop. The additive and
|
|
152
|
+
* subtractive forms below are thin projections over this list, so they can
|
|
153
|
+
* never drift in which terms they include, and a new offset contributor is
|
|
154
|
+
* added here once rather than in every consumer (#1693).
|
|
155
|
+
*/
|
|
156
|
+
function loopOffsetTerms(offset: LoopOffset | undefined): string[] {
|
|
157
|
+
if (!offset) return []
|
|
158
|
+
const terms: string[] = []
|
|
159
|
+
if (offset.staticCount) terms.push(String(offset.staticCount))
|
|
160
|
+
terms.push(...offset.dynamicTerms)
|
|
161
|
+
return terms
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the additive `children[idx]` access expression for a loop's items —
|
|
166
|
+
* `indexParam` plus every offset term.
|
|
167
|
+
*
|
|
168
|
+
* Examples:
|
|
169
|
+
* - no offset → `__idx`
|
|
170
|
+
* - one static sibling → `__idx + 1`
|
|
171
|
+
* - one preceding `.map()` → `__idx + (arr).length`
|
|
172
|
+
* - static sibling + 2 `.map()`→ `__idx + 1 + (a).length + (b).length`
|
|
173
|
+
*/
|
|
174
|
+
export function buildLoopChildIndexExpr(indexParam: string, offset: LoopOffset | undefined): string {
|
|
175
|
+
return [indexParam, ...loopOffsetTerms(offset)].join(' + ')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the subtractive counterpart of `buildLoopChildIndexExpr` — used by
|
|
180
|
+
* event delegation to recover a loop item's array index from its DOM child
|
|
181
|
+
* index. Returns the trailing `` - <static> - (arr).length …`` suffix (empty
|
|
182
|
+
* when there is no offset) appended after `…indexOf(__el)`.
|
|
183
|
+
*/
|
|
184
|
+
export function buildLoopChildIndexSubtraction(offset: LoopOffset | undefined): string {
|
|
185
|
+
return loopOffsetTerms(offset).map(term => ` - ${term}`).join('')
|
|
186
|
+
}
|
|
187
|
+
|
|
148
188
|
/**
|
|
149
189
|
* Map of JSX event names to DOM event property names.
|
|
150
190
|
* JSX uses React-style naming (e.g., onDoubleClick) which gets converted to
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
replaceInExprContexts,
|
|
4
|
+
findInterpolationEnd,
|
|
5
|
+
findTopLevelTemplateLiterals,
|
|
6
|
+
} from '../js-scanner'
|
|
7
|
+
import { tokenContainsIdent, wrapLoopParamAsAccessor } from '../../ir-to-client-js/utils'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fuzz harness for the shared `ts.createScanner`-based JS-text scanner (#1370).
|
|
11
|
+
*
|
|
12
|
+
* The bug class this issue targets — hand-rolled char-by-char scanners that
|
|
13
|
+
* disagree about where "code context" ends — only shows up on inputs that mix
|
|
14
|
+
* quotes, comments, escapes and regex tokens in adversarial ways. The
|
|
15
|
+
* example-based suite in `js-scanner.test.ts` pins specific known cases; this
|
|
16
|
+
* file generates random combinations and asserts that *every* consumer of the
|
|
17
|
+
* shared lexer honours the same invariant:
|
|
18
|
+
*
|
|
19
|
+
* a bare `MARK` identifier is a real code reference **iff** it sits in
|
|
20
|
+
* expression context — never when it appears inside a string, template
|
|
21
|
+
* string body, comment or regex literal.
|
|
22
|
+
*
|
|
23
|
+
* Each generated input is paired with an exact oracle computed from the atoms
|
|
24
|
+
* it was assembled from, so the assertions are precise (not just "didn't
|
|
25
|
+
* throw"). Generation is seeded per-iteration with a deterministic PRNG, and
|
|
26
|
+
* the seed + input are surfaced on failure so any regression reproduces.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// --- deterministic PRNG (mulberry32) ---------------------------------------
|
|
30
|
+
function mulberry32(seed: number): () => number {
|
|
31
|
+
let a = seed >>> 0
|
|
32
|
+
return () => {
|
|
33
|
+
a = (a + 0x6d2b79f5) | 0
|
|
34
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
35
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
36
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pick = <T>(rng: () => number, arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)]!
|
|
41
|
+
|
|
42
|
+
// Alphabet deliberately loaded with the delimiters that fooled the old
|
|
43
|
+
// per-consumer scanners: every quote flavour, comment markers, regex slashes,
|
|
44
|
+
// braces, escapes and the marker letters themselves.
|
|
45
|
+
const NOISE_ALPHABET = [
|
|
46
|
+
'a', 'M', 'A', 'R', 'K', 'q', 'z', ' ',
|
|
47
|
+
"'", '"', '`', '/', '*', '{', '}', '$', '\\', ';', '(', ')', '[', ']',
|
|
48
|
+
] as const
|
|
49
|
+
|
|
50
|
+
function randNoise(rng: () => number, maxLen = 10): string {
|
|
51
|
+
const len = Math.floor(rng() * (maxLen + 1))
|
|
52
|
+
let s = ''
|
|
53
|
+
for (let i = 0; i < len; i++) s += pick(rng, NOISE_ALPHABET)
|
|
54
|
+
return s
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- per-context sanitizers: make `raw` safe to embed without changing how it
|
|
58
|
+
// lexes, so the embedded text round-trips byte-for-byte in the oracle. ---
|
|
59
|
+
const escDouble = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '')
|
|
60
|
+
const escSingle = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/[\r\n]/g, '')
|
|
61
|
+
// No-substitution template: kill backticks and any `${` interpolation opener.
|
|
62
|
+
const escTemplate = (raw: string) => raw.replace(/\\/g, '\\\\').replace(/`/g, '').replace(/\$/g, '').replace(/[\r\n]/g, '')
|
|
63
|
+
const escLine = (raw: string) => raw.replace(/[\r\n]/g, '')
|
|
64
|
+
const escBlock = (raw: string) => raw.replace(/[\r\n]/g, '').replace(/\*\//g, '* /')
|
|
65
|
+
// Regex body: escape the delimiter and drop char-class brackets so the literal
|
|
66
|
+
// always terminates at the next `/`. Prefix a literal char so it is never empty
|
|
67
|
+
// (which would read as `//` line comment) nor start with `*`.
|
|
68
|
+
const escRegex = (raw: string) =>
|
|
69
|
+
'x' + raw.replace(/\\/g, '\\\\').replace(/\//g, '\\/').replace(/\[/g, '\\[').replace(/\]/g, '\\]').replace(/[\r\n]/g, '')
|
|
70
|
+
|
|
71
|
+
interface Atom {
|
|
72
|
+
/** Source text of the atom as it appears in the input. */
|
|
73
|
+
src: string
|
|
74
|
+
/** What this atom becomes after a `MARK` -> `MARK()` expression-context rewrite. */
|
|
75
|
+
rewritten: string
|
|
76
|
+
/** True only for a bare-identifier `MARK` sitting in code context. */
|
|
77
|
+
isCodeMarker: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Plain code fragments that carry no `MARK` and no structural `{`/`}` (so the
|
|
81
|
+
// only top-level braces in any input come from the `${...}` wrap we add later).
|
|
82
|
+
const CODE_PLAIN = ['a', 'b.c', 'foo(1)', 'arr[0]', 'n / 2', 'x + y', 'obj.prop', '1.5'] as const
|
|
83
|
+
|
|
84
|
+
function makeAtom(rng: () => number): Atom {
|
|
85
|
+
const kind = pick(rng, [
|
|
86
|
+
'codeMarker', 'codeMarker', 'codePlain',
|
|
87
|
+
'dq', 'sq', 'tmpl', 'line', 'block', 'regex',
|
|
88
|
+
] as const)
|
|
89
|
+
switch (kind) {
|
|
90
|
+
case 'codeMarker':
|
|
91
|
+
return { src: 'MARK', rewritten: 'MARK()', isCodeMarker: true }
|
|
92
|
+
case 'codePlain': {
|
|
93
|
+
const src = pick(rng, CODE_PLAIN)
|
|
94
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
95
|
+
}
|
|
96
|
+
case 'dq': {
|
|
97
|
+
const src = '"' + escDouble(randNoise(rng)) + '"'
|
|
98
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
99
|
+
}
|
|
100
|
+
case 'sq': {
|
|
101
|
+
const src = "'" + escSingle(randNoise(rng)) + "'"
|
|
102
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
103
|
+
}
|
|
104
|
+
case 'tmpl': {
|
|
105
|
+
const src = '`' + escTemplate(randNoise(rng)) + '`'
|
|
106
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
107
|
+
}
|
|
108
|
+
case 'line': {
|
|
109
|
+
// Trailing newline terminates the comment so the joining ` + ` that
|
|
110
|
+
// follows stays in code context.
|
|
111
|
+
const src = '// ' + escLine(randNoise(rng)) + '\n'
|
|
112
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
113
|
+
}
|
|
114
|
+
case 'block': {
|
|
115
|
+
const src = '/* ' + escBlock(randNoise(rng)) + ' */'
|
|
116
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
117
|
+
}
|
|
118
|
+
case 'regex': {
|
|
119
|
+
const src = '/' + escRegex(randNoise(rng)) + '/' + pick(rng, ['', 'g', 'i', 'gi', 'm'])
|
|
120
|
+
return { src, rewritten: src, isCodeMarker: false }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Generated {
|
|
126
|
+
input: string
|
|
127
|
+
/** Input after every code-context `MARK` is rewritten to `MARK()`. */
|
|
128
|
+
expected: string
|
|
129
|
+
/** Whether at least one `MARK` reference lives in code context. */
|
|
130
|
+
hasCodeMarker: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function generate(rng: () => number): Generated {
|
|
134
|
+
const count = 1 + Math.floor(rng() * 8)
|
|
135
|
+
const atoms: Atom[] = []
|
|
136
|
+
for (let i = 0; i < count; i++) atoms.push(makeAtom(rng))
|
|
137
|
+
// ` + ` keeps the stream lexable and puts every regex atom in a
|
|
138
|
+
// regex-start position (after an operator).
|
|
139
|
+
const input = atoms.map(a => a.src).join(' + ')
|
|
140
|
+
const expected = atoms.map(a => a.rewritten).join(' + ')
|
|
141
|
+
const hasCodeMarker = atoms.some(a => a.isCodeMarker)
|
|
142
|
+
return { input, expected, hasCodeMarker }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ITERATIONS = 600
|
|
146
|
+
|
|
147
|
+
describe('js-scanner fuzz: code-context opacity holds across all consumers', () => {
|
|
148
|
+
test('replaceInExprContexts rewrites MARK only outside strings/comments/regex/templates', () => {
|
|
149
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
150
|
+
const seed = (0x9e3779b9 ^ i) >>> 0
|
|
151
|
+
const { input, expected } = generate(mulberry32(seed))
|
|
152
|
+
const got = replaceInExprContexts(input, /\bMARK\b/g, 'MARK()')
|
|
153
|
+
expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(expected)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('wrapLoopParamAsAccessor agrees with replaceInExprContexts on the same inputs', () => {
|
|
158
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
159
|
+
const seed = (0x85ebca6b ^ i) >>> 0
|
|
160
|
+
const { input, expected } = generate(mulberry32(seed))
|
|
161
|
+
const got = wrapLoopParamAsAccessor(input, 'MARK')
|
|
162
|
+
expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(expected)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('tokenContainsIdent reports MARK iff a code-context occurrence exists', () => {
|
|
167
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
168
|
+
const seed = (0xc2b2ae35 ^ i) >>> 0
|
|
169
|
+
const { input, hasCodeMarker } = generate(mulberry32(seed))
|
|
170
|
+
const got = tokenContainsIdent(input, 'MARK')
|
|
171
|
+
expect(got, `seed=${seed} input=${JSON.stringify(input)}`).toBe(hasCodeMarker)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('findInterpolationEnd finds the real closing brace despite braces in noise', () => {
|
|
176
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
177
|
+
const seed = (0x27d4eb2f ^ i) >>> 0
|
|
178
|
+
const { input } = generate(mulberry32(seed))
|
|
179
|
+
// Braces only appear inside opaque tokens (strings/regex/comments/
|
|
180
|
+
// templates) of `input`; the wrap adds the single top-level pair.
|
|
181
|
+
const wrapped = '${' + input + '}'
|
|
182
|
+
const end = findInterpolationEnd(wrapped, 2)
|
|
183
|
+
expect(end, `seed=${seed} wrapped=${JSON.stringify(wrapped)}`).toBe(wrapped.length - 1)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// findTopLevelTemplateLiterals operates on a ternary shape; fuzz its branch
|
|
189
|
+
// bodies with noise that must stay inside the backtick literals.
|
|
190
|
+
describe('js-scanner fuzz: findTopLevelTemplateLiterals extracts noisy branches', () => {
|
|
191
|
+
test('returns both backtick branches verbatim regardless of embedded delimiters', () => {
|
|
192
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
193
|
+
const seed = (0x165667b1 ^ i) >>> 0
|
|
194
|
+
const rng = mulberry32(seed)
|
|
195
|
+
const a = escTemplate(randNoise(rng))
|
|
196
|
+
const b = escTemplate(randNoise(rng))
|
|
197
|
+
const src = 'cond ? `' + a + '` : `' + b + '`'
|
|
198
|
+
const got = findTopLevelTemplateLiterals(src)
|
|
199
|
+
expect(got, `seed=${seed} src=${JSON.stringify(src)}`).toEqual([a, b])
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -1291,6 +1291,12 @@ export interface TypeDefinition {
|
|
|
1291
1291
|
kind: 'interface' | 'type'
|
|
1292
1292
|
name: string
|
|
1293
1293
|
definition: string // Original TypeScript definition
|
|
1294
|
+
/**
|
|
1295
|
+
* Structured fields for object/interface shapes, so adapters can consume the
|
|
1296
|
+
* field set (names + types) without re-parsing `definition`. Absent for
|
|
1297
|
+
* type aliases that aren't object types (e.g. string-literal unions).
|
|
1298
|
+
*/
|
|
1299
|
+
properties?: PropertyInfo[]
|
|
1294
1300
|
loc: SourceLocation
|
|
1295
1301
|
}
|
|
1296
1302
|
|