ripple 0.2.172 → 0.2.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.172",
6
+ "version": "0.2.174",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -78,9 +78,10 @@
78
78
  "devDependencies": {
79
79
  "@types/estree": "^1.0.8",
80
80
  "@types/node": "^24.3.0",
81
- "typescript": "^5.9.2"
81
+ "typescript": "^5.9.2",
82
+ "@volar/language-core": "~2.4.23"
82
83
  },
83
84
  "peerDependencies": {
84
- "ripple": "0.2.172"
85
+ "ripple": "0.2.174"
85
86
  }
86
87
  }
@@ -25,20 +25,22 @@ export function parse(source) {
25
25
  export function compile(source, filename, options = {}) {
26
26
  const ast = parse_module(source);
27
27
  const analysis = analyze(ast, filename, options);
28
- const result = options.mode === 'server'
29
- ? transform_server(filename, source, analysis)
30
- : transform_client(filename, source, analysis, false);
28
+ const result =
29
+ options.mode === 'server'
30
+ ? transform_server(filename, source, analysis)
31
+ : transform_client(filename, source, analysis, false);
31
32
 
32
33
  return result;
33
34
  }
34
35
 
35
36
  /** @import { PostProcessingChanges, LineOffsets } from './phases/3-transform/client/index.js' */
37
+ /** @import { MappingsResult } from './phases/3-transform/segments.js' */
36
38
 
37
39
  /**
38
40
  * Compile Ripple component to Volar virtual code with TypeScript mappings
39
41
  * @param {string} source
40
42
  * @param {string} filename
41
- * @returns {object} Volar mappings object
43
+ * @returns {MappingsResult} Volar mappings object
42
44
  */
43
45
  export function compile_to_volar_mappings(source, filename) {
44
46
  const ast = parse_module(source);
@@ -51,7 +53,7 @@ export function compile_to_volar_mappings(source, filename) {
51
53
  source,
52
54
  transformed.js.code,
53
55
  transformed.js.map,
54
- /** @type {PostProcessingChanges} */(transformed.js.post_processing_changes),
55
- /** @type {LineOffsets} */(transformed.js.line_offsets)
56
+ /** @type {PostProcessingChanges} */ (transformed.js.post_processing_changes),
57
+ /** @type {LineOffsets} */ (transformed.js.line_offsets),
56
58
  );
57
59
  }
@@ -1,19 +1,31 @@
1
+ /** @typedef {import('@volar/language-core').CodeMapping} VolarCodeMapping */
2
+
3
+ /**
4
+ * @typedef {Object} CustomMappingData
5
+ * @property {[number]} generatedLengths
6
+ */
7
+
8
+ /**
9
+ * @typedef {VolarCodeMapping & {
10
+ * data: VolarCodeMapping['data'] & {
11
+ * customData: CustomMappingData
12
+ * }
13
+ * }} CodeMapping
14
+ */
15
+
16
+ /** @typedef {{code: string, mappings: CodeMapping[]}} MappingsResult */
17
+
1
18
  import { walk } from 'zimmerframe';
2
19
  import { build_source_to_generated_map, get_generated_position } from '../../source-map-utils.js';
3
20
 
21
+ /** @type {VolarCodeMapping['data']} */
4
22
  export const mapping_data = {
5
23
  verification: true,
6
24
  completion: true,
7
25
  semantic: true,
8
26
  navigation: true,
9
- rename: true,
10
- codeActions: false, // set to false to disable auto import when importing yourself
11
- formatting: false, // not doing formatting through Volar, using Prettier.
12
- // these 3 below will be true by default
13
- // leaving for reference
14
- // hover: true,
15
- // definition: true,
16
- // references: true,
27
+ structure: true,
28
+ format: false,
17
29
  };
18
30
 
19
31
  /**
@@ -28,14 +40,24 @@ export const mapping_data = {
28
40
  * @param {object} esrap_source_map - Esrap source map for accurate position lookup
29
41
  * @param {PostProcessingChanges } post_processing_changes - Optional post-processing changes
30
42
  * @param {number[]} line_offsets - Pre-computed line offsets array for generated code
31
- * @returns {{ code: string, mappings: Array<{sourceOffsets: number[], generatedOffsets: number[], lengths: number[], data: any}> }}
43
+ * @returns {MappingsResult}
32
44
  */
33
- export function convert_source_map_to_mappings(ast, source, generated_code, esrap_source_map, post_processing_changes, line_offsets) {
34
- /** @type {Array<{sourceOffsets: number[], generatedOffsets: number[], lengths: number[], data: any}>} */
45
+ export function convert_source_map_to_mappings(
46
+ ast,
47
+ source,
48
+ generated_code,
49
+ esrap_source_map,
50
+ post_processing_changes,
51
+ line_offsets,
52
+ ) {
53
+ /** @type {CodeMapping[]} */
35
54
  const mappings = [];
36
55
 
37
- // Build line offset maps for source and generated code
38
- // This allows us to convert line/column positions to byte offsets
56
+ /**
57
+ * Converts line/column positions to byte offsets
58
+ * @param {string} text
59
+ * @returns {number[]}
60
+ */
39
61
  const build_line_offsets = (text) => {
40
62
  const offsets = [0]; // Line 1 starts at offset 0
41
63
  for (let i = 0; i < text.length; i++) {
@@ -73,7 +95,7 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
73
95
  const adjusted_source_map = build_source_to_generated_map(
74
96
  esrap_source_map,
75
97
  post_processing_changes,
76
- line_offsets
98
+ line_offsets,
77
99
  );
78
100
 
79
101
  // Collect text tokens from AST nodes
@@ -96,11 +118,19 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
96
118
  if (node.name && node.loc) {
97
119
  // Check if this identifier has tracked_shorthand metadata (e.g., TrackedMap -> #Map)
98
120
  if (node.metadata?.tracked_shorthand) {
99
- tokens.push({ source: node.metadata.tracked_shorthand, generated: node.name, loc: node.loc });
121
+ tokens.push({
122
+ source: node.metadata.tracked_shorthand,
123
+ generated: node.name,
124
+ loc: node.loc,
125
+ });
100
126
  } else if (node.metadata?.is_capitalized) {
101
127
  // This identifier was capitalized during transformation
102
128
  // Map the original lowercase name to the capitalized generated name
103
- tokens.push({ source: node.metadata.original_name, generated: node.name, loc: node.loc });
129
+ tokens.push({
130
+ source: node.metadata.original_name,
131
+ generated: node.name,
132
+ loc: node.loc,
133
+ });
104
134
  } else {
105
135
  // No transformation - source and generated names are the same
106
136
  tokens.push({ source: node.name, generated: node.name, loc: node.loc });
@@ -111,7 +141,11 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
111
141
  // JSXIdentifiers can also be capitalized (for dynamic components)
112
142
  if (node.loc && node.name) {
113
143
  if (node.metadata?.is_capitalized) {
114
- tokens.push({ source: node.metadata.original_name, generated: node.name, loc: node.loc });
144
+ tokens.push({
145
+ source: node.metadata.original_name,
146
+ generated: node.name,
147
+ loc: node.loc,
148
+ });
115
149
  } else {
116
150
  tokens.push({ source: node.name, generated: node.name, loc: node.loc });
117
151
  }
@@ -130,7 +164,7 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
130
164
  source: '',
131
165
  generated: '',
132
166
  loc: node.loc,
133
- is_import_statement: true
167
+ is_import_statement: true,
134
168
  });
135
169
  }
136
170
 
@@ -152,7 +186,10 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
152
186
  visit(node.local);
153
187
  }
154
188
  return;
155
- } else if (node.type === 'ImportDefaultSpecifier' || node.type === 'ImportNamespaceSpecifier') {
189
+ } else if (
190
+ node.type === 'ImportDefaultSpecifier' ||
191
+ node.type === 'ImportNamespaceSpecifier'
192
+ ) {
156
193
  // Just visit local
157
194
  if (node.local) {
158
195
  visit(node.local);
@@ -246,17 +283,32 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
246
283
  }
247
284
 
248
285
  // 3. Push closing tag name (not visited by AST walker)
249
- if (!node.openingElement?.selfClosing && node.closingElement?.name?.type === 'JSXIdentifier') {
286
+ if (
287
+ !node.openingElement?.selfClosing &&
288
+ node.closingElement?.name?.type === 'JSXIdentifier'
289
+ ) {
250
290
  const closingNameNode = node.closingElement.name;
251
291
  if (closingNameNode.metadata?.is_capitalized) {
252
- tokens.push({ source: closingNameNode.metadata.original_name, generated: closingNameNode.name, loc: closingNameNode.loc });
292
+ tokens.push({
293
+ source: closingNameNode.metadata.original_name,
294
+ generated: closingNameNode.name,
295
+ loc: closingNameNode.loc,
296
+ });
253
297
  } else {
254
- tokens.push({ source: closingNameNode.name, generated: closingNameNode.name, loc: closingNameNode.loc });
298
+ tokens.push({
299
+ source: closingNameNode.name,
300
+ generated: closingNameNode.name,
301
+ loc: closingNameNode.loc,
302
+ });
255
303
  }
256
304
  }
257
305
 
258
306
  return;
259
- } else if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
307
+ } else if (
308
+ node.type === 'FunctionDeclaration' ||
309
+ node.type === 'FunctionExpression' ||
310
+ node.type === 'ArrowFunctionExpression'
311
+ ) {
260
312
  // Add function/component keyword token
261
313
  if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') {
262
314
  const source_keyword = node.metadata?.was_component ? 'component' : 'function';
@@ -266,8 +318,11 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
266
318
  generated: 'function',
267
319
  loc: {
268
320
  start: { line: node.loc.start.line, column: node.loc.start.column },
269
- end: { line: node.loc.start.line, column: node.loc.start.column + source_keyword.length }
270
- }
321
+ end: {
322
+ line: node.loc.start.line,
323
+ column: node.loc.start.column + source_keyword.length,
324
+ },
325
+ },
271
326
  });
272
327
  }
273
328
 
@@ -654,7 +709,11 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
654
709
  }
655
710
  }
656
711
  return;
657
- } else if (node.type === 'JSXClosingElement' || node.type === 'JSXClosingFragment' || node.type === 'JSXOpeningFragment') {
712
+ } else if (
713
+ node.type === 'JSXClosingElement' ||
714
+ node.type === 'JSXClosingFragment' ||
715
+ node.type === 'JSXOpeningFragment'
716
+ ) {
658
717
  // These are handled by their parent nodes
659
718
  return;
660
719
  } else if (node.type === 'JSXMemberExpression') {
@@ -736,7 +795,10 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
736
795
  }
737
796
  // Skip typeAnnotation
738
797
  return;
739
- } else if (node.type === 'TSTypeParameterInstantiation' || node.type === 'TSTypeParameterDeclaration') {
798
+ } else if (
799
+ node.type === 'TSTypeParameterInstantiation' ||
800
+ node.type === 'TSTypeParameterDeclaration'
801
+ ) {
740
802
  // Generic type parameters - visit to collect type variable names
741
803
  if (node.params) {
742
804
  for (const param of node.params) {
@@ -881,7 +943,10 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
881
943
  visit(node.typeAnnotation);
882
944
  }
883
945
  return;
884
- } else if (node.type === 'TSCallSignatureDeclaration' || node.type === 'TSConstructSignatureDeclaration') {
946
+ } else if (
947
+ node.type === 'TSCallSignatureDeclaration' ||
948
+ node.type === 'TSConstructSignatureDeclaration'
949
+ ) {
885
950
  // Call or construct signature
886
951
  if (node.typeParameters) {
887
952
  visit(node.typeParameters);
@@ -1078,7 +1143,22 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
1078
1143
  visit(node.typeAnnotation);
1079
1144
  }
1080
1145
  return;
1081
- } else if (node.type === 'TSAnyKeyword' || node.type === 'TSUnknownKeyword' || node.type === 'TSNumberKeyword' || node.type === 'TSObjectKeyword' || node.type === 'TSBooleanKeyword' || node.type === 'TSBigIntKeyword' || node.type === 'TSStringKeyword' || node.type === 'TSSymbolKeyword' || node.type === 'TSVoidKeyword' || node.type === 'TSUndefinedKeyword' || node.type === 'TSNullKeyword' || node.type === 'TSNeverKeyword' || node.type === 'TSThisType' || node.type === 'TSIntrinsicKeyword') {
1146
+ } else if (
1147
+ node.type === 'TSAnyKeyword' ||
1148
+ node.type === 'TSUnknownKeyword' ||
1149
+ node.type === 'TSNumberKeyword' ||
1150
+ node.type === 'TSObjectKeyword' ||
1151
+ node.type === 'TSBooleanKeyword' ||
1152
+ node.type === 'TSBigIntKeyword' ||
1153
+ node.type === 'TSStringKeyword' ||
1154
+ node.type === 'TSSymbolKeyword' ||
1155
+ node.type === 'TSVoidKeyword' ||
1156
+ node.type === 'TSUndefinedKeyword' ||
1157
+ node.type === 'TSNullKeyword' ||
1158
+ node.type === 'TSNeverKeyword' ||
1159
+ node.type === 'TSThisType' ||
1160
+ node.type === 'TSIntrinsicKeyword'
1161
+ ) {
1082
1162
  // Primitive type keywords - leaf nodes, no children
1083
1163
  return;
1084
1164
  } else if (node.type === 'TSDeclareFunction') {
@@ -1120,23 +1200,40 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
1120
1200
  }
1121
1201
 
1122
1202
  throw new Error(`Unhandled AST node type in mapping walker: ${node.type}`);
1123
- }
1203
+ },
1124
1204
  });
1125
1205
 
1126
1206
  // Process each token in order
1127
1207
  // All tokens now have .loc property - no need for fallback logic
1128
1208
  for (const token of tokens) {
1129
1209
  const source_text = token.source;
1210
+ const gen_text = token.generated;
1130
1211
 
1131
1212
  // Handle import statement full-statement mapping
1132
1213
  if (token.is_import_statement) {
1133
1214
  // Get source position from start
1134
- const source_start = loc_to_offset(token.loc.start.line, token.loc.start.column, source_line_offsets);
1135
- const source_end = loc_to_offset(token.loc.end.line, token.loc.end.column, source_line_offsets);
1215
+ const source_start = loc_to_offset(
1216
+ token.loc.start.line,
1217
+ token.loc.start.column,
1218
+ source_line_offsets,
1219
+ );
1220
+ const source_end = loc_to_offset(
1221
+ token.loc.end.line,
1222
+ token.loc.end.column,
1223
+ source_line_offsets,
1224
+ );
1136
1225
 
1137
1226
  // Get generated positions using source map
1138
- const gen_start_pos = get_generated_position(token.loc.start.line, token.loc.start.column, adjusted_source_map);
1139
- const gen_end_pos = get_generated_position(token.loc.end.line, token.loc.end.column, adjusted_source_map);
1227
+ const gen_start_pos = get_generated_position(
1228
+ token.loc.start.line,
1229
+ token.loc.start.column,
1230
+ adjusted_source_map,
1231
+ );
1232
+ const gen_end_pos = get_generated_position(
1233
+ token.loc.end.line,
1234
+ token.loc.end.column,
1235
+ adjusted_source_map,
1236
+ );
1140
1237
 
1141
1238
  if (source_start !== null && source_end !== null && gen_start_pos && gen_end_pos) {
1142
1239
  // Convert generated line:col to byte offsets
@@ -1152,7 +1249,11 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
1152
1249
  lengths: [Math.min(source_length, gen_length)],
1153
1250
  data: {
1154
1251
  // only verification (diagnostics) to avoid duplicate hover/completion
1155
- verification: true
1252
+ verification: true,
1253
+
1254
+ customData: {
1255
+ generatedLengths: [gen_text.length],
1256
+ },
1156
1257
  },
1157
1258
  });
1158
1259
  }
@@ -1160,25 +1261,42 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
1160
1261
  }
1161
1262
 
1162
1263
  // Use .loc to get the exact source position
1163
- const source_pos = loc_to_offset(token.loc.start.line, token.loc.start.column, source_line_offsets);
1264
+ const source_pos = loc_to_offset(
1265
+ token.loc.start.line,
1266
+ token.loc.start.column,
1267
+ source_line_offsets,
1268
+ );
1164
1269
 
1165
1270
  // Get generated position using source map
1166
- const gen_line_col = get_generated_position(token.loc.start.line, token.loc.start.column, adjusted_source_map);
1271
+ const gen_line_col = get_generated_position(
1272
+ token.loc.start.line,
1273
+ token.loc.start.column,
1274
+ adjusted_source_map,
1275
+ );
1167
1276
  let gen_pos = null;
1168
1277
  if (gen_line_col) {
1169
1278
  // Convert generated line:col to byte offset
1170
1279
  gen_pos = gen_loc_to_offset(gen_line_col.line, gen_line_col.column);
1171
1280
  } else {
1172
1281
  // No mapping found in source map - this shouldn't happen since all tokens should have mappings
1173
- console.warn(`[segments.js] No source map entry for token "${source_text}" at ${token.loc.start.line}:${token.loc.start.column}`);
1282
+ console.warn(
1283
+ `[segments.js] No source map entry for token "${source_text}" at ${token.loc.start.line}:${token.loc.start.column}`,
1284
+ );
1174
1285
  }
1175
1286
 
1176
1287
  if (source_pos !== null && gen_pos !== null) {
1288
+ // !IMPORTANT: don't set generatedLengths, otherwise Volar will use that vs our source
1289
+ // We're adding it to our custom metadata instead as we need it for patching positions
1177
1290
  mappings.push({
1178
1291
  sourceOffsets: [source_pos],
1179
1292
  generatedOffsets: [gen_pos],
1180
1293
  lengths: [source_text.length],
1181
- data: mapping_data,
1294
+ data: {
1295
+ ...mapping_data,
1296
+ customData: {
1297
+ generatedLengths: [gen_text.length],
1298
+ },
1299
+ },
1182
1300
  });
1183
1301
  }
1184
1302
  }
@@ -1195,9 +1313,10 @@ export function convert_source_map_to_mappings(ast, source, generated_code, esra
1195
1313
  lengths: [1],
1196
1314
  data: {
1197
1315
  ...mapping_data,
1198
- codeActions: true, // auto-import
1199
- rename: false, // avoid rename for a “dummy” mapping
1200
- }
1316
+ customData: {
1317
+ generatedLengths: [1],
1318
+ },
1319
+ },
1201
1320
  });
1202
1321
  }
1203
1322
 
@@ -88,6 +88,7 @@ export {
88
88
  bindContentBoxSize,
89
89
  bindBorderBoxSize,
90
90
  bindDevicePixelContentBoxSize,
91
+ bindFiles,
91
92
  bindIndeterminate,
92
93
  bindInnerHTML,
93
94
  bindInnerText,
@@ -256,10 +256,10 @@ export function bindChecked(maybe_tracked) {
256
256
  throw not_tracked_type_error('bindChecked()');
257
257
  }
258
258
 
259
- const tracked = /** @type {Tracked} */ (maybe_tracked);
259
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
260
260
 
261
261
  return (input) => {
262
- const clear_event = on(input, 'change', () => {
262
+ var clear_event = on(input, 'change', () => {
263
263
  set(tracked, input.checked);
264
264
  });
265
265
 
@@ -281,10 +281,10 @@ export function bindIndeterminate(maybe_tracked) {
281
281
  throw not_tracked_type_error('bindIndeterminate()');
282
282
  }
283
283
 
284
- const tracked = /** @type {Tracked} */ (maybe_tracked);
284
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
285
285
 
286
286
  return (input) => {
287
- const clear_event = on(input, 'change', () => {
287
+ var clear_event = on(input, 'change', () => {
288
288
  set(tracked, input.indeterminate);
289
289
  });
290
290
 
@@ -311,32 +311,28 @@ export function bindGroup(maybe_tracked) {
311
311
  return (input) => {
312
312
  var is_checkbox = input.getAttribute('type') === 'checkbox';
313
313
 
314
- // Store the input's value
315
- // @ts-ignore
316
- input.__value = input.value;
317
-
318
314
  var clear_event = on(input, 'change', () => {
319
- // @ts-ignore
320
- var value = input.__value;
315
+ var value = input.value;
316
+ var result;
321
317
 
322
318
  if (is_checkbox) {
323
319
  /** @type {Array<any>} */
324
- var current_value = get(tracked) || [];
320
+ var list = get(tracked) || [];
325
321
 
326
322
  if (input.checked) {
327
- // Add if not already present
328
- if (!current_value.includes(value)) {
329
- value = [...current_value, value];
323
+ if (!list.includes(value)) {
324
+ result = [...list, value];
330
325
  } else {
331
- value = current_value;
326
+ result = list;
332
327
  }
333
328
  } else {
334
- // Remove the value
335
- value = current_value.filter((v) => v !== value);
329
+ result = list.filter((v) => v !== value);
336
330
  }
331
+ } else {
332
+ result = input.value;
337
333
  }
338
334
 
339
- set(tracked, value);
335
+ set(tracked, result);
340
336
  });
341
337
 
342
338
  effect(() => {
@@ -344,11 +340,9 @@ export function bindGroup(maybe_tracked) {
344
340
 
345
341
  if (is_checkbox) {
346
342
  value = value || [];
347
- // @ts-ignore
348
- input.checked = value.includes(input.__value);
343
+ input.checked = value.includes(input.value);
349
344
  } else {
350
- // @ts-ignore
351
- input.checked = value === input.__value;
345
+ input.checked = value === input.value;
352
346
  }
353
347
  });
354
348
 
@@ -373,7 +367,7 @@ function bind_element_size(maybe_tracked, type) {
373
367
  );
374
368
 
375
369
  effect(() => {
376
- untrack(() => set(tracked, element[type]));
370
+ set(tracked, element[type]);
377
371
  return unsubscribe;
378
372
  });
379
373
  };
@@ -480,10 +474,10 @@ export function bind_content_editable(maybe_tracked, property) {
480
474
  throw not_tracked_type_error(`bind${property.charAt(0).toUpperCase() + property.slice(1)}()`);
481
475
  }
482
476
 
483
- const tracked = /** @type {Tracked} */ (maybe_tracked);
477
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
484
478
 
485
479
  return (element) => {
486
- const clear_event = on(element, 'input', () => {
480
+ var clear_event = on(element, 'input', () => {
487
481
  set(tracked, element[property]);
488
482
  });
489
483
 
@@ -530,6 +524,34 @@ export function bindTextContent(maybe_tracked) {
530
524
  return bind_content_editable(maybe_tracked, 'textContent');
531
525
  }
532
526
 
527
+ /**
528
+ * @param {unknown} maybe_tracked
529
+ * @returns {(node: HTMLInputElement) => void}
530
+ */
531
+ export function bindFiles(maybe_tracked) {
532
+ if (!is_tracked_object(maybe_tracked)) {
533
+ throw not_tracked_type_error('bindFiles()');
534
+ }
535
+
536
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
537
+
538
+ return (input) => {
539
+ var clear_event = on(input, 'change', () => {
540
+ set(tracked, input.files);
541
+ });
542
+
543
+ effect(() => {
544
+ var value = get(tracked);
545
+
546
+ if (value !== input.files && value instanceof FileList) {
547
+ input.files = value;
548
+ }
549
+ });
550
+
551
+ return clear_event;
552
+ };
553
+ }
554
+
533
555
  /**
534
556
  * Syntactic sugar for binding a HTMLElement with {ref fn}
535
557
  * @param {unknown} maybe_tracked
@@ -540,7 +562,7 @@ export function bindNode(maybe_tracked) {
540
562
  throw not_tracked_type_error('bindNode()');
541
563
  }
542
564
 
543
- const tracked = /** @type {Tracked} */ (maybe_tracked);
565
+ var tracked = /** @type {Tracked} */ (maybe_tracked);
544
566
 
545
567
  /** @param {HTMLElement} node */
546
568
  return (node) => {
@@ -369,8 +369,10 @@ export function destroy_block(block, remove_dom = true) {
369
369
 
370
370
  if ((remove_dom && (f & (BRANCH_BLOCK | ROOT_BLOCK)) !== 0) || (f & HEAD_BLOCK) !== 0) {
371
371
  var s = block.s;
372
- remove_block_dom(s.start, s.end);
373
- removed = true;
372
+ if (s !== null) {
373
+ remove_block_dom(s.start, s.end);
374
+ removed = true;
375
+ }
374
376
  }
375
377
 
376
378
  destroy_block_children(block, remove_dom && !removed);
@@ -129,7 +129,7 @@ function apply_styles(element, new_styles, prev) {
129
129
  * @param {string} key
130
130
  * @param {any} value
131
131
  * @param {Record<string, (() => void) | undefined>} remove_listeners
132
- * @param {Record<string, any>} prev
132
+ * @param {Record<string | symbol, any>} prev
133
133
  */
134
134
  function set_attribute_helper(element, key, value, remove_listeners, prev) {
135
135
  if (key === 'class') {
@@ -284,4 +284,29 @@ describe('basic client > components & composition', () => {
284
284
  render(App);
285
285
  }).not.toThrow();
286
286
  });
287
+
288
+ it('handles component without any output', () => {
289
+ component Noop() {
290
+ // No output
291
+ }
292
+
293
+ component Op() {
294
+ <div>{'Some HTML content'}</div>
295
+ }
296
+
297
+ component App() {
298
+ let Content = track(() => Noop);
299
+ <@Content />
300
+
301
+ <button onClick={() => @Content = Op}>{'Show Op'}</button>
302
+ }
303
+
304
+ render(App);
305
+
306
+ const button = container.querySelector('button');
307
+
308
+ expect(() => {
309
+ button.click();
310
+ }).not.toThrow();
311
+ });
287
312
  });
@@ -81,7 +81,7 @@ describe('composite > props', () => {
81
81
  logs.push(@count);
82
82
  });
83
83
 
84
- <button onClick={() => @count = @count + 1}>{'+'}</button>
84
+ <button onClick={() => (@count = @count + 1)}>{'+'}</button>
85
85
  }
86
86
 
87
87
  component App() {
@@ -122,7 +122,7 @@ describe('composite > props', () => {
122
122
 
123
123
  component Toggle(props) {
124
124
  const [pressed, rest] = trackSplit(props, ['pressed']);
125
- const onClick = () => @pressed = !@pressed;
125
+ const onClick = () => (@pressed = !@pressed);
126
126
  <Button {...@rest} class={@pressed ? 'on' : 'off'} {onClick}>{'button 1'}</Button>
127
127
  <Button class={@pressed ? 'on' : 'off'} {onClick}>{'button 2'}</Button>
128
128
  }
@@ -18,6 +18,7 @@ import {
18
18
  bindInnerText,
19
19
  bindTextContent,
20
20
  bindNode,
21
+ bindFiles,
21
22
  } from 'ripple';
22
23
 
23
24
  // Mock ResizeObserver for testing
@@ -57,9 +58,37 @@ function triggerResize(element: Element, entry: Partial<ResizeObserverEntry>) {
57
58
  }
58
59
  }
59
60
 
61
+ // Mock DataTransfer for testing file inputs
62
+ class MockDataTransfer {
63
+ items: MockDataTransferItemList;
64
+ files: FileList;
65
+
66
+ constructor() {
67
+ this.items = new MockDataTransferItemList();
68
+ this.files = this.items.files;
69
+ }
70
+ }
71
+
72
+ class MockDataTransferItemList {
73
+ _files: File[] = [];
74
+
75
+ get files(): FileList {
76
+ return this._files as any as FileList;
77
+ }
78
+
79
+ add(file: File): void {
80
+ this._files.push(file);
81
+ }
82
+
83
+ get length(): number {
84
+ return this._files.length;
85
+ }
86
+ }
87
+
60
88
  // Setup ResizeObserver mock
61
89
  beforeAll(() => {
62
90
  (global as any).ResizeObserver = createMockResizeObserver;
91
+ (global as any).DataTransfer = MockDataTransfer;
63
92
  });
64
93
 
65
94
  afterAll(() => {
@@ -1227,6 +1256,265 @@ describe('bindNode', () => {
1227
1256
  });
1228
1257
  });
1229
1258
 
1259
+ describe('bindFiles', () => {
1260
+ it('should bind files from file input', () => {
1261
+ const logs: FileList[] = [];
1262
+
1263
+ component App() {
1264
+ const files = track(null);
1265
+
1266
+ effect(() => {
1267
+ @files;
1268
+ if (@files) logs.push(@files);
1269
+ });
1270
+
1271
+ <input type="file" multiple {ref bindFiles(files)} />
1272
+ }
1273
+
1274
+ render(App);
1275
+ flushSync();
1276
+
1277
+ const input = container.querySelector('input') as HTMLInputElement;
1278
+
1279
+ // Create mock FileList using DataTransfer
1280
+ const dt = new DataTransfer();
1281
+ const file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' });
1282
+ const file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' });
1283
+ dt.items.add(file1);
1284
+ dt.items.add(file2);
1285
+
1286
+ // Simulate file selection
1287
+ Object.defineProperty(input, 'files', {
1288
+ value: dt.files,
1289
+ writable: true,
1290
+ });
1291
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1292
+ flushSync();
1293
+
1294
+ expect(logs.length).toBeGreaterThan(0);
1295
+ const lastFiles = logs[logs.length - 1];
1296
+ expect(lastFiles.length).toBe(2);
1297
+ expect(lastFiles[0].name).toBe('file1.txt');
1298
+ expect(lastFiles[1].name).toBe('file2.txt');
1299
+ });
1300
+
1301
+ it('should update tracked value when files are selected', () => {
1302
+ let capturedFiles: FileList | null = null;
1303
+
1304
+ component App() {
1305
+ const files = track(null);
1306
+
1307
+ effect(() => {
1308
+ capturedFiles = @files;
1309
+ });
1310
+
1311
+ <input type="file" {ref bindFiles(files)} />
1312
+ }
1313
+
1314
+ render(App);
1315
+ flushSync();
1316
+
1317
+ const input = container.querySelector('input') as HTMLInputElement;
1318
+
1319
+ // Create mock file
1320
+ const dt = new DataTransfer();
1321
+ const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
1322
+ dt.items.add(file);
1323
+
1324
+ Object.defineProperty(input, 'files', {
1325
+ value: dt.files,
1326
+ writable: true,
1327
+ });
1328
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1329
+ flushSync();
1330
+
1331
+ expect(capturedFiles).not.toBeNull();
1332
+ expect(capturedFiles?.length).toBe(1);
1333
+ expect(capturedFiles?.[0].name).toBe('test.txt');
1334
+ });
1335
+
1336
+ it('should allow clearing files via input.files', () => {
1337
+ let capturedFiles: FileList | null = null;
1338
+
1339
+ component App() {
1340
+ const files = track(null);
1341
+ const input = track(null);
1342
+
1343
+ effect(() => {
1344
+ capturedFiles = @files;
1345
+ });
1346
+
1347
+ <div>
1348
+ <input type="file" {ref bindFiles(files)} {ref bindNode(input)} />
1349
+ <button
1350
+ onClick={() => {
1351
+ if (@input) {
1352
+ @input.files = new DataTransfer().files;
1353
+ @input.dispatchEvent(new Event('change', { bubbles: true }));
1354
+ }
1355
+ }}
1356
+ >
1357
+ {'Clear'}
1358
+ </button>
1359
+ </div>
1360
+ }
1361
+
1362
+ render(App);
1363
+ flushSync();
1364
+
1365
+ const input = container.querySelector('input') as HTMLInputElement;
1366
+ const button = container.querySelector('button') as HTMLButtonElement;
1367
+
1368
+ // Add a file first
1369
+ const dt = new DataTransfer();
1370
+ const file = new File(['content'], 'file.txt', { type: 'text/plain' });
1371
+ dt.items.add(file);
1372
+
1373
+ Object.defineProperty(input, 'files', {
1374
+ value: dt.files,
1375
+ writable: true,
1376
+ });
1377
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1378
+ flushSync();
1379
+
1380
+ expect(capturedFiles?.length).toBe(1);
1381
+
1382
+ // Clear via button
1383
+ button.click();
1384
+ flushSync();
1385
+
1386
+ expect(capturedFiles?.length).toBe(0);
1387
+ });
1388
+
1389
+ it('should handle multiple file selections', () => {
1390
+ const fileCounts: number[] = [];
1391
+
1392
+ component App() {
1393
+ const files = track(null);
1394
+
1395
+ effect(() => {
1396
+ @files;
1397
+ if (@files) {
1398
+ fileCounts.push(@files.length);
1399
+ }
1400
+ });
1401
+
1402
+ <input type="file" multiple {ref bindFiles(files)} />
1403
+ }
1404
+
1405
+ render(App);
1406
+ flushSync();
1407
+
1408
+ const input = container.querySelector('input') as HTMLInputElement;
1409
+
1410
+ // First selection: 2 files
1411
+ const dt1 = new DataTransfer();
1412
+ dt1.items.add(new File(['a'], 'a.txt'));
1413
+ dt1.items.add(new File(['b'], 'b.txt'));
1414
+
1415
+ Object.defineProperty(input, 'files', {
1416
+ value: dt1.files,
1417
+ writable: true,
1418
+ });
1419
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1420
+ flushSync();
1421
+
1422
+ // Second selection: 3 files
1423
+ const dt2 = new DataTransfer();
1424
+ dt2.items.add(new File(['x'], 'x.txt'));
1425
+ dt2.items.add(new File(['y'], 'y.txt'));
1426
+ dt2.items.add(new File(['z'], 'z.txt'));
1427
+
1428
+ Object.defineProperty(input, 'files', {
1429
+ value: dt2.files,
1430
+ writable: true,
1431
+ });
1432
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1433
+ flushSync();
1434
+
1435
+ expect(fileCounts).toEqual([2, 3]);
1436
+ });
1437
+
1438
+ it('should handle file input without multiple attribute', () => {
1439
+ let capturedFile: File | null = null;
1440
+
1441
+ component App() {
1442
+ const files = track(null);
1443
+
1444
+ effect(() => {
1445
+ @files;
1446
+ if (@files && @files.length > 0) {
1447
+ capturedFile = @files[0];
1448
+ }
1449
+ });
1450
+
1451
+ <input type="file" {ref bindFiles(files)} />
1452
+ }
1453
+
1454
+ render(App);
1455
+ flushSync();
1456
+
1457
+ const input = container.querySelector('input') as HTMLInputElement;
1458
+
1459
+ const dt = new DataTransfer();
1460
+ const file = new File(['single file content'], 'single.pdf', { type: 'application/pdf' });
1461
+ dt.items.add(file);
1462
+
1463
+ Object.defineProperty(input, 'files', {
1464
+ value: dt.files,
1465
+ writable: true,
1466
+ });
1467
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1468
+ flushSync();
1469
+
1470
+ expect(capturedFile).not.toBeNull();
1471
+ expect(capturedFile?.name).toBe('single.pdf');
1472
+ expect(capturedFile?.type).toBe('application/pdf');
1473
+ });
1474
+
1475
+ it('should handle empty file selection', () => {
1476
+ const logs: (FileList | null)[] = [];
1477
+
1478
+ component App() {
1479
+ const files = track(null);
1480
+
1481
+ effect(() => {
1482
+ logs.push(@files);
1483
+ });
1484
+
1485
+ <input type="file" {ref bindFiles(files)} />
1486
+ }
1487
+
1488
+ render(App);
1489
+ flushSync();
1490
+
1491
+ const input = container.querySelector('input') as HTMLInputElement;
1492
+
1493
+ // Select a file
1494
+ const dt = new DataTransfer();
1495
+ dt.items.add(new File(['test'], 'test.txt'));
1496
+
1497
+ Object.defineProperty(input, 'files', {
1498
+ value: dt.files,
1499
+ writable: true,
1500
+ });
1501
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1502
+ flushSync();
1503
+
1504
+ // Clear selection
1505
+ Object.defineProperty(input, 'files', {
1506
+ value: new DataTransfer().files,
1507
+ writable: true,
1508
+ });
1509
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1510
+ flushSync();
1511
+
1512
+ expect(logs.length).toBeGreaterThan(1);
1513
+ const lastFiles = logs[logs.length - 1];
1514
+ expect(lastFiles?.length).toBe(0);
1515
+ });
1516
+ });
1517
+
1230
1518
  describe('error handling', () => {
1231
1519
  it('should throw error when bindValue receives non-tracked object', () => {
1232
1520
  expect(() => {
@@ -1371,4 +1659,13 @@ describe('error handling', () => {
1371
1659
  render(App);
1372
1660
  }).toThrow('bindNode() argument is not a tracked object');
1373
1661
  });
1662
+
1663
+ it('should throw error when bindFiles receives non-tracked object', () => {
1664
+ expect(() => {
1665
+ component App() {
1666
+ <input type="file" {ref bindFiles({ value: null })} />
1667
+ }
1668
+ render(App);
1669
+ }).toThrow('bindFiles() argument is not a tracked object');
1670
+ });
1374
1671
  });
package/types/index.d.ts CHANGED
@@ -307,3 +307,5 @@ export declare function bindOffsetHeight<V>(tracked: Tracked<V>): (node: HTMLEle
307
307
  export declare function bindOffsetWidth<V>(tracked: Tracked<V>): (node: HTMLElement) => void;
308
308
 
309
309
  export declare function bindIndeterminate<V>(tracked: Tracked<V>): (node: HTMLInputElement) => void;
310
+
311
+ export declare function bindFiles<V>(tracked: Tracked<V>): (node: HTMLInputElement) => void;