@barefootjs/client 0.1.3 → 0.3.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.
@@ -294,6 +294,12 @@ function hydrateCommentScope(comment: Comment): void {
294
294
  const proxyEl = nextElementSibling(comment) ?? comment.parentElement
295
295
  if (!proxyEl) return
296
296
 
297
+ // A synchronous CSR render() may have already initialized this fragment
298
+ // scope and claimed its proxy before this async walk runs. Honour the same
299
+ // `hydratedScopes` signal the element-scope path uses (hydrateElementScope),
300
+ // so a comment-rooted scope is never re-initialized once claimed.
301
+ if (hydratedScopes.has(proxyEl)) return
302
+
297
303
  commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId })
298
304
 
299
305
  const parsed = parseProps(propsJson || null, `comment scope ${scopeId}`)
@@ -277,6 +277,17 @@ export function mapArray<T>(
277
277
  scopes.set(key, scope)
278
278
  insertScope(scope, container, anchor)
279
279
  }
280
+
281
+ // If client has fewer items than SSR rendered, remove orphaned nodes
282
+ for (let i = items.length; i < existingRanges.length; i++) {
283
+ const range = existingRanges[i]
284
+ if (range.startMarker?.parentNode) range.startMarker.remove()
285
+ if (range.primaryEl.parentNode) range.primaryEl.remove()
286
+ for (const ex of range.extras) {
287
+ if (ex.parentNode) ex.remove()
288
+ }
289
+ }
290
+
280
291
  return // Hydration complete — effects handle future updates
281
292
  }
282
293
  }
@@ -6,10 +6,11 @@
6
6
  * never import this module.
7
7
  */
8
8
 
9
- import { BF_SCOPE } from '@barefootjs/shared'
9
+ import { BF_SCOPE, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
10
10
  import { setParentScopeId } from './component'
11
11
  import { hydratedScopes } from './hydration-state'
12
12
  import { getComponentInit } from './registry'
13
+ import { commentScopeRegistry } from './scope'
13
14
  import { getTemplate, type TemplateFn } from './template'
14
15
  import type { ComponentDef, InitFn } from './types'
15
16
 
@@ -86,20 +87,41 @@ export function render(
86
87
 
87
88
  const tpl = document.createElement('template')
88
89
  tpl.innerHTML = html
89
- const element = tpl.content.firstChild as HTMLElement
90
90
 
91
- if (!element) {
92
- throw new Error('[BarefootJS] render(): template returned empty HTML')
93
- }
91
+ const rootElements = Array.from(tpl.content.childNodes).filter(
92
+ (n): n is HTMLElement => n.nodeType === Node.ELEMENT_NODE
93
+ )
94
94
 
95
- if (!element.getAttribute(BF_SCOPE)) {
96
- element.setAttribute(BF_SCOPE, scopeId)
95
+ if (rootElements.length === 0) {
96
+ throw new Error('[BarefootJS] render(): template returned empty HTML')
97
97
  }
98
98
 
99
99
  container.innerHTML = ''
100
- container.appendChild(element)
101
100
 
102
- init(element, props)
101
+ if (rootElements.length === 1) {
102
+ const element = rootElements[0]
103
+ if (!element.getAttribute(BF_SCOPE)) {
104
+ element.setAttribute(BF_SCOPE, scopeId)
105
+ }
106
+ container.appendChild(element)
107
+ init(element, props)
108
+ hydratedScopes.add(element)
109
+ return
110
+ }
111
+
112
+ // Multi-root (fragment) template: the child scopes are siblings, not
113
+ // descendants of one root, so $c() can't resolve them by subtree walk.
114
+ // Recreate the SSR fragment layout — a `bf-scope:` comment marker followed
115
+ // by the sibling roots — and register it so candidatesInScope() walks the
116
+ // comment range. Without this only the first root would hydrate.
117
+ const commentNode = document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`)
118
+ container.appendChild(commentNode)
119
+ for (const node of Array.from(tpl.content.childNodes)) {
120
+ container.appendChild(node)
121
+ }
103
122
 
104
- hydratedScopes.add(element)
123
+ const proxyEl = rootElements[0]
124
+ commentScopeRegistry.set(proxyEl, { commentNode, scopeId })
125
+ init(proxyEl, props)
126
+ hydratedScopes.add(proxyEl)
105
127
  }
@@ -6,83 +6,33 @@
6
6
  * a static string for server/template rendering of computed local spreads.
7
7
  */
8
8
 
9
+ import { classifyDOMProp } from '@barefootjs/shared'
9
10
  import { styleToCss } from './style'
10
11
 
11
- /**
12
- * SVG attributes that are case-sensitive and MUST stay in camelCase.
13
- *
14
- * The default JSX-prop → HTML-attribute rewrite lower-cases camelCase
15
- * (`fooBar` → `foo-bar`), which is correct for HTML attrs and for the
16
- * CSS-style SVG presentation attrs (`strokeWidth` → `stroke-width`).
17
- * The XML-namespaced SVG attrs below, though, are case-sensitive in
18
- * the spec: `viewBox` lower-cased to `view-box` makes the browser
19
- * treat it as an unknown user attribute and the SVG no longer renders
20
- * (pointer events stop hitting the inner geometry — surfaced as the
21
- * Form Builder e2e regression in #1244's merge-emit follow-up).
22
- *
23
- * Coordinates with the compile-time `SVG_CAMEL_TO_KEBAB` table in
24
- * `packages/jsx/src/ir-to-client-js/utils.ts`: presentation attrs
25
- * (`clipPath`, `strokeWidth`, …) live there and must NOT appear here,
26
- * or the same JSX prop would lower to `clip-path` via the explicit-
27
- * attr path and stay `clipPath` via the spread path — a silent
28
- * divergence. The list below is XML attribute names that have no
29
- * kebab-case mirror (`viewBox`, `clipPathUnits`, …).
30
- *
31
- * The list mirrors React DOM's `DOMProperty` case-preserving entries
32
- * (only the attributes that appear on actual SVG elements; ARIA and
33
- * XLink namespaces are unrelated and handled by their `aria-*` /
34
- * `xlink:*` literal prefix).
35
- */
36
- const SVG_CAMEL_CASE_ATTRS: ReadonlySet<string> = new Set([
37
- 'allowReorder', 'attributeName', 'attributeType', 'autoReverse',
38
- 'baseFrequency', 'baseProfile', 'calcMode', 'clipPathUnits',
39
- 'contentScriptType', 'contentStyleType', 'diffuseConstant', 'edgeMode',
40
- 'externalResourcesRequired', 'filterRes', 'filterUnits', 'glyphRef',
41
- 'gradientTransform', 'gradientUnits', 'kernelMatrix', 'kernelUnitLength',
42
- 'keyPoints', 'keySplines', 'keyTimes', 'lengthAdjust', 'limitingConeAngle',
43
- 'markerHeight', 'markerUnits', 'markerWidth', 'maskContentUnits',
44
- 'maskUnits', 'numOctaves', 'pathLength', 'patternContentUnits',
45
- 'patternTransform', 'patternUnits', 'pointsAtX', 'pointsAtY', 'pointsAtZ',
46
- 'preserveAlpha', 'preserveAspectRatio', 'primitiveUnits', 'refX', 'refY',
47
- 'repeatCount', 'repeatDur', 'requiredExtensions', 'requiredFeatures',
48
- 'specularConstant', 'specularExponent', 'spreadMethod', 'startOffset',
49
- 'stdDeviation', 'stitchTiles', 'surfaceScale', 'systemLanguage',
50
- 'tableValues', 'targetX', 'targetY', 'textLength', 'viewBox', 'viewTarget',
51
- 'xChannelSelector', 'yChannelSelector', 'zoomAndPan',
52
- ])
53
-
54
12
  /**
55
13
  * Convert an object to an HTML attribute string.
56
- * Aligned with applyRestAttrs conventions: skips null/undefined/false,
57
- * event handlers, maps className→class and htmlFor→for. The `style`
58
- * prop is routed through `styleToCss` so object literals serialize to
59
- * a real CSS string (matching the reactive `applyRestAttrs` path).
60
- *
61
- * SVG attributes listed in `SVG_CAMEL_CASE_ATTRS` are preserved
62
- * verbatim — the SVG XML spec is case-sensitive for those names.
14
+ * Uses the shared classifyDOMProp classifier to determine how each prop
15
+ * maps to the DOM. Skips null/undefined/false, event handlers, ref, and
16
+ * children. The `style` prop is routed through `styleToCss` so object
17
+ * literals serialize to a real CSS string.
63
18
  */
64
19
  export function spreadAttrs(obj: Record<string, unknown>): string {
65
20
  if (!obj || typeof obj !== 'object') return ''
66
21
  const parts: string[] = []
67
22
  for (const [key, value] of Object.entries(obj)) {
68
23
  if (value == null || value === false) continue
69
- // Skip event handlers
70
- if (key.startsWith('on') && key.length > 2 && key[2] === key[2].toUpperCase()) continue
71
- // Skip children prop
72
- if (key === 'children') continue
73
- if (key === 'style') {
24
+ const c = classifyDOMProp(key)
25
+ if (c.kind === 'event' || c.kind === 'skip' || c.kind === 'ref') continue
26
+ if (c.kind === 'style') {
74
27
  const css = styleToCss(value)
75
28
  if (css != null) parts.push(`style="${css}"`)
76
29
  continue
77
30
  }
78
- // Map JSX prop names to HTML attribute names. Case-sensitive SVG
79
- // attrs keep their camelCase per the spec; HTML / CSS-style SVG
80
- // presentation attrs lower-case to kebab-case.
81
- const attr = key === 'className' ? 'class'
82
- : key === 'htmlFor' ? 'for'
83
- : SVG_CAMEL_CASE_ATTRS.has(key) ? key
84
- : key.replace(/([A-Z])/g, '-$1').toLowerCase()
85
- parts.push(value === true ? attr : `${attr}="${value}"`)
31
+ if (c.kind === 'boolean' && value === true) {
32
+ parts.push(c.attrName)
33
+ } else {
34
+ parts.push(`${c.attrName}="${value}"`)
35
+ }
86
36
  }
87
37
  return parts.join(' ')
88
38
  }