@barefootjs/client 0.2.0 → 0.4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/runtime/component.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAS3C,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAExD;AAWD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH;;;GAGG;AACH;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAA;IACd,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,GAAG,YAAY,EAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EACrB,IAAI,CAAC,EAAE,uBAAuB,GAC7B,WAAW,CAyHb;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAE3F;AAuBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC,GAAG,SAAS,CAE7G;AAGD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EACrB,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAiDR;AA+DD;;;;;GAKG;AACH;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,gBAAgB,CAcjF"}
1
+ {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/runtime/component.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAS3C,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAExD;AAWD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH;;;GAGG;AACH;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAA;IACd,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,GAAG,YAAY,EAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EACrB,IAAI,CAAC,EAAE,uBAAuB,GAC7B,WAAW,CAoIb;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAE3F;AAuBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC,GAAG,SAAS,CAE7G;AAGD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EACrB,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAiDR;AA+DD;;;;;GAKG;AACH;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,gBAAgB,CAcjF"}
@@ -916,6 +916,8 @@ function hydrateCommentScope(comment) {
916
916
  const proxyEl = nextElementSibling2(comment) ?? comment.parentElement;
917
917
  if (!proxyEl)
918
918
  return;
919
+ if (hydratedScopes.has(proxyEl))
920
+ return;
919
921
  commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId });
920
922
  const parsed = parseProps(propsJson || null, `comment scope ${scopeId}`);
921
923
  const props = parsed[name] ?? {};
@@ -970,9 +972,14 @@ function createComponent(nameOrDef, props, key, slot) {
970
972
  }
971
973
  return result;
972
974
  });
975
+ const def = getRegisteredDef(name);
976
+ const isCommentWrapper = def?.comment === true;
977
+ const scopeId = isCommentWrapper ? null : `${name}_${generateId()}`;
973
978
  const prevParentScopeId = _parentScopeId;
974
979
  if (slot?.parent) {
975
980
  _parentScopeId = slot.parent;
981
+ } else if (scopeId) {
982
+ _parentScopeId = scopeId;
976
983
  }
977
984
  let html;
978
985
  try {
@@ -985,10 +992,8 @@ function createComponent(nameOrDef, props, key, slot) {
985
992
  console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`);
986
993
  return createPlaceholder(name, key);
987
994
  }
988
- const def = getRegisteredDef(name);
989
- const isCommentWrapper = def?.comment === true;
990
- if (!isCommentWrapper) {
991
- element.setAttribute(BF_SCOPE, `${name}_${generateId()}`);
995
+ if (scopeId) {
996
+ element.setAttribute(BF_SCOPE, scopeId);
992
997
  }
993
998
  if (slot) {
994
999
  if (slot.parent)
@@ -1633,6 +1638,17 @@ function mapArray(accessor, container, getKey, renderItem, markerId) {
1633
1638
  scopes.set(key, scope);
1634
1639
  insertScope(scope, container, anchor);
1635
1640
  }
1641
+ for (let i = items.length;i < existingRanges.length; i++) {
1642
+ const range = existingRanges[i];
1643
+ if (range.startMarker?.parentNode)
1644
+ range.startMarker.remove();
1645
+ if (range.primaryEl.parentNode)
1646
+ range.primaryEl.remove();
1647
+ for (const ex of range.extras) {
1648
+ if (ex.parentNode)
1649
+ ex.remove();
1650
+ }
1651
+ }
1636
1652
  return;
1637
1653
  }
1638
1654
  }
@@ -2075,17 +2091,30 @@ function render(container, nameOrDef, props = {}) {
2075
2091
  }
2076
2092
  const tpl = document.createElement("template");
2077
2093
  tpl.innerHTML = html;
2078
- const element = tpl.content.firstChild;
2079
- if (!element) {
2094
+ const rootElements = Array.from(tpl.content.childNodes).filter((n) => n.nodeType === Node.ELEMENT_NODE);
2095
+ if (rootElements.length === 0) {
2080
2096
  throw new Error("[BarefootJS] render(): template returned empty HTML");
2081
2097
  }
2082
- if (!element.getAttribute(BF_SCOPE)) {
2083
- element.setAttribute(BF_SCOPE, scopeId);
2084
- }
2085
2098
  container.innerHTML = "";
2086
- container.appendChild(element);
2087
- init(element, props);
2088
- hydratedScopes.add(element);
2099
+ if (rootElements.length === 1) {
2100
+ const element = rootElements[0];
2101
+ if (!element.getAttribute(BF_SCOPE)) {
2102
+ element.setAttribute(BF_SCOPE, scopeId);
2103
+ }
2104
+ container.appendChild(element);
2105
+ init(element, props);
2106
+ hydratedScopes.add(element);
2107
+ return;
2108
+ }
2109
+ const commentNode = document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`);
2110
+ container.appendChild(commentNode);
2111
+ for (const node of Array.from(tpl.content.childNodes)) {
2112
+ container.appendChild(node);
2113
+ }
2114
+ const proxyEl = rootElements[0];
2115
+ commentScopeRegistry.set(proxyEl, { commentNode, scopeId });
2116
+ init(proxyEl, props);
2117
+ hydratedScopes.add(proxyEl);
2089
2118
  }
2090
2119
  // src/runtime/streaming.ts
2091
2120
  function __bf_swap(id) {
@@ -1 +1 @@
1
- {"version":3,"file":"map-array.d.ts","sourceRoot":"","sources":["../../src/runtime/map-array.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA4LH;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,QAAQ,EAAE,MAAM,CAAC,EAAE,EACnB,SAAS,EAAE,WAAW,GAAG,IAAI,EAC7B,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,IAAI,EACnD,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,WAAW,KAAK,WAAW,EACjF,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CA6JN"}
1
+ {"version":3,"file":"map-array.d.ts","sourceRoot":"","sources":["../../src/runtime/map-array.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA4LH;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,QAAQ,EAAE,MAAM,CAAC,EAAE,EACnB,SAAS,EAAE,WAAW,GAAG,IAAI,EAC7B,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,IAAI,EACnD,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,WAAW,KAAK,WAAW,EACjF,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAwKN"}
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/runtime/render.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,YAAY,EAAU,MAAM,SAAS,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,MAAM,CACpB,SAAS,EAAE,WAAW,EACtB,SAAS,EAAE,MAAM,GAAG,YAAY,EAChC,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CA4DN"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/runtime/render.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAU,MAAM,SAAS,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,MAAM,CACpB,SAAS,EAAE,WAAW,EACtB,SAAS,EAAE,MAAM,GAAG,YAAY,EAChC,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CAiFN"}
@@ -1107,6 +1107,8 @@ function hydrateCommentScope(comment) {
1107
1107
  const proxyEl = nextElementSibling2(comment) ?? comment.parentElement;
1108
1108
  if (!proxyEl)
1109
1109
  return;
1110
+ if (hydratedScopes.has(proxyEl))
1111
+ return;
1110
1112
  commentScopeRegistry.set(proxyEl, { commentNode: comment, scopeId });
1111
1113
  const parsed = parseProps(propsJson || null, `comment scope ${scopeId}`);
1112
1114
  const props = parsed[name] ?? {};
@@ -1160,9 +1162,14 @@ function createComponent(nameOrDef, props, key, slot) {
1160
1162
  }
1161
1163
  return result;
1162
1164
  });
1165
+ const def = getRegisteredDef(name);
1166
+ const isCommentWrapper = def?.comment === true;
1167
+ const scopeId = isCommentWrapper ? null : `${name}_${generateId()}`;
1163
1168
  const prevParentScopeId = _parentScopeId;
1164
1169
  if (slot?.parent) {
1165
1170
  _parentScopeId = slot.parent;
1171
+ } else if (scopeId) {
1172
+ _parentScopeId = scopeId;
1166
1173
  }
1167
1174
  let html;
1168
1175
  try {
@@ -1175,10 +1182,8 @@ function createComponent(nameOrDef, props, key, slot) {
1175
1182
  console.warn(`[BarefootJS] Template returned empty HTML for component: ${name}`);
1176
1183
  return createPlaceholder(name, key);
1177
1184
  }
1178
- const def = getRegisteredDef(name);
1179
- const isCommentWrapper = def?.comment === true;
1180
- if (!isCommentWrapper) {
1181
- element.setAttribute(BF_SCOPE, `${name}_${generateId()}`);
1185
+ if (scopeId) {
1186
+ element.setAttribute(BF_SCOPE, scopeId);
1182
1187
  }
1183
1188
  if (slot) {
1184
1189
  if (slot.parent)
@@ -1822,6 +1827,17 @@ function mapArray(accessor, container, getKey, renderItem, markerId) {
1822
1827
  scopes.set(key, scope);
1823
1828
  insertScope(scope, container, anchor);
1824
1829
  }
1830
+ for (let i = items.length;i < existingRanges.length; i++) {
1831
+ const range = existingRanges[i];
1832
+ if (range.startMarker?.parentNode)
1833
+ range.startMarker.remove();
1834
+ if (range.primaryEl.parentNode)
1835
+ range.primaryEl.remove();
1836
+ for (const ex of range.extras) {
1837
+ if (ex.parentNode)
1838
+ ex.remove();
1839
+ }
1840
+ }
1825
1841
  return;
1826
1842
  }
1827
1843
  }
@@ -2260,17 +2276,30 @@ function render(container, nameOrDef, props = {}) {
2260
2276
  }
2261
2277
  const tpl = document.createElement("template");
2262
2278
  tpl.innerHTML = html;
2263
- const element = tpl.content.firstChild;
2264
- if (!element) {
2279
+ const rootElements = Array.from(tpl.content.childNodes).filter((n) => n.nodeType === Node.ELEMENT_NODE);
2280
+ if (rootElements.length === 0) {
2265
2281
  throw new Error("[BarefootJS] render(): template returned empty HTML");
2266
2282
  }
2267
- if (!element.getAttribute(BF_SCOPE)) {
2268
- element.setAttribute(BF_SCOPE, scopeId);
2269
- }
2270
2283
  container.innerHTML = "";
2271
- container.appendChild(element);
2272
- init(element, props);
2273
- hydratedScopes.add(element);
2284
+ if (rootElements.length === 1) {
2285
+ const element = rootElements[0];
2286
+ if (!element.getAttribute(BF_SCOPE)) {
2287
+ element.setAttribute(BF_SCOPE, scopeId);
2288
+ }
2289
+ container.appendChild(element);
2290
+ init(element, props);
2291
+ hydratedScopes.add(element);
2292
+ return;
2293
+ }
2294
+ const commentNode = document.createComment(`${BF_SCOPE_COMMENT_PREFIX}${scopeId}`);
2295
+ container.appendChild(commentNode);
2296
+ for (const node of Array.from(tpl.content.childNodes)) {
2297
+ container.appendChild(node);
2298
+ }
2299
+ const proxyEl = rootElements[0];
2300
+ commentScopeRegistry.set(proxyEl, { commentNode, scopeId });
2301
+ init(proxyEl, props);
2302
+ hydratedScopes.add(proxyEl);
2274
2303
  }
2275
2304
  // src/runtime/streaming.ts
2276
2305
  function __bf_swap(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/client",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,10 +55,10 @@
55
55
  "directory": "packages/client"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.2.0"
58
+ "@barefootjs/shared": "0.4.0"
59
59
  },
60
60
  "peerDependencies": {
61
- "@barefootjs/jsx": "0.2.0"
61
+ "@barefootjs/jsx": ">=0.2.0"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "@barefootjs/jsx": {
@@ -66,7 +66,6 @@
66
66
  }
67
67
  },
68
68
  "devDependencies": {
69
- "@barefootjs/jsx": "0.2.0",
70
69
  "@happy-dom/global-registrator": "^20.0.11",
71
70
  "typescript": "^5.0.0"
72
71
  }
@@ -124,13 +124,31 @@ export function createComponent(
124
124
  return result
125
125
  })
126
126
 
127
- // 4. Generate HTML from props.
127
+ // 4. Pre-generate the component's scope ID.
128
128
  //
129
- // Thread `slot.parent` into `_parentScopeId` so any hoisted-children
130
- // placeholder (#1320) resolves to the calling site's scope.
129
+ // `comment: true` components (synthesized inline-JSX-callback wrappers
130
+ // from #1211) render as transparent shells — the parsed `firstChild` is
131
+ // already the inner component's root with its own bf-s. Don't overwrite
132
+ // it (scopeId stays null), or `$c(__scope, 's0')` from the wrapper's
133
+ // init resolves to null.
134
+ const def = getRegisteredDef(name)
135
+ const isCommentWrapper = def?.comment === true
136
+ const scopeId = isCommentWrapper ? null : `${name}_${generateId()}`
137
+
138
+ // 5. Generate HTML from props.
139
+ //
140
+ // Thread the component's own scope ID into `_parentScopeId` for the
141
+ // template eval so renderChild() stamps parent-prefixed bf-s / bf-h /
142
+ // bf-m on child components — matching the SSR convention so a later
143
+ // `$c(scope, 'sN')` lookup resolves them. Without this, CSR-created
144
+ // children carry a random prefix and their event handlers never wire
145
+ // up (#1627). `slot.parent` takes precedence so hoisted-children
146
+ // placeholders (#1320) still resolve to the calling site's scope.
131
147
  const prevParentScopeId = _parentScopeId
132
148
  if (slot?.parent) {
133
149
  _parentScopeId = slot.parent
150
+ } else if (scopeId) {
151
+ _parentScopeId = scopeId
134
152
  }
135
153
  let html: string
136
154
  try {
@@ -139,7 +157,7 @@ export function createComponent(
139
157
  _parentScopeId = prevParentScopeId
140
158
  }
141
159
 
142
- // 5. Create DOM element
160
+ // 6. Create DOM element
143
161
  const element = parseHTML(html.trim()).firstChild as HTMLElement
144
162
 
145
163
  if (!element) {
@@ -147,16 +165,9 @@ export function createComponent(
147
165
  return createPlaceholder(name, key)
148
166
  }
149
167
 
150
- // 6. Set scope ID and key attributes.
151
- //
152
- // `comment: true` components (synthesized inline-JSX-callback wrappers
153
- // from #1211) render as transparent shells — the parsed `firstChild` is
154
- // already the inner component's root with its own bf-s. Don't overwrite
155
- // it, or `$c(__scope, 's0')` from the wrapper's init resolves to null.
156
- const def = getRegisteredDef(name)
157
- const isCommentWrapper = def?.comment === true
158
- if (!isCommentWrapper) {
159
- element.setAttribute(BF_SCOPE, `${name}_${generateId()}`)
168
+ // 7. Set scope ID and key attributes.
169
+ if (scopeId) {
170
+ element.setAttribute(BF_SCOPE, scopeId)
160
171
  }
161
172
  if (slot) {
162
173
  if (slot.parent) element.setAttribute(BF_HOST, slot.parent)
@@ -166,18 +177,18 @@ export function createComponent(
166
177
  element.setAttribute(BF_KEY, String(key))
167
178
  }
168
179
 
169
- // 7. Set currentScope so provideContext/useContext are element-scoped.
180
+ // 8. Set currentScope so provideContext/useContext are element-scoped.
170
181
  // This allows context providers in initFn to store context on this element.
171
182
  const prevScope = setCurrentScope(element)
172
183
 
173
- // 8. Initialize the component (context providers set up here).
184
+ // 9. Initialize the component (context providers set up here).
174
185
  const initFn = getComponentInit(name)
175
186
  if (initFn) {
176
187
  // Pass original props (with getters) for reactivity
177
188
  initFn(element, props)
178
189
  }
179
190
 
180
- // 9. Evaluate getter children and insert them.
191
+ // 10. Evaluate getter children and insert them.
181
192
  // Children are evaluated NOW (after initFn) so that context provided by
182
193
  // the parent is in the global store when children call useContext().
183
194
  if (childrenIsGetter) {
@@ -187,13 +198,13 @@ export function createComponent(
187
198
  }
188
199
  }
189
200
 
190
- // 10. Restore previous scope
201
+ // 11. Restore previous scope
191
202
  setCurrentScope(prevScope)
192
203
 
193
- // 11. Mark element as initialized
204
+ // 12. Mark element as initialized
194
205
  hydratedScopes.add(element)
195
206
 
196
- // 12. Store props and register update function for element reuse in reconcileList
207
+ // 13. Store props and register update function for element reuse in reconcileList
197
208
  propsMap.set(element, props)
198
209
  registerPropsUpdate(element, name, props)
199
210
 
@@ -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
  }