ripple 0.2.90 → 0.2.91

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.90",
6
+ "version": "0.2.91",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -455,24 +455,30 @@ function RipplePlugin(config) {
455
455
  jsx_parseExpressionContainer() {
456
456
  let node = this.startNode();
457
457
  this.next();
458
- let tracked = false;
458
+ let tracked = false;
459
459
 
460
- if (this.value === 'html') {
461
- node.html = true;
462
- this.next();
463
- if (this.type.label === '@') {
464
- this.next(); // consume @
465
- tracked = true;
466
- }
467
- }
460
+ if (this.value === 'html') {
461
+ node.html = true;
462
+ this.next();
463
+ if (this.type === tt.braceR) {
464
+ this.raise(
465
+ this.start,
466
+ '"html" is a Ripple keyword and must be used in the form {html some_content}',
467
+ );
468
+ }
469
+ if (this.type.label === '@') {
470
+ this.next(); // consume @
471
+ tracked = true;
472
+ }
473
+ }
468
474
 
469
475
  node.expression =
470
476
  this.type === tt.braceR ? this.jsx_parseEmptyExpression() : this.parseExpression();
471
477
  this.expect(tt.braceR);
472
478
 
473
- if (tracked && node.expression.type === 'Identifier') {
474
- node.expression.tracked = true;
475
- }
479
+ if (tracked && node.expression.type === 'Identifier') {
480
+ node.expression.tracked = true;
481
+ }
476
482
 
477
483
  return this.finishNode(node, 'JSXExpressionContainer');
478
484
  }
@@ -970,7 +976,7 @@ function RipplePlugin(config) {
970
976
  if (this.type.label === '{') {
971
977
  const node = this.jsx_parseExpressionContainer();
972
978
  node.type = node.html ? 'Html' : 'Text';
973
- delete node.html;
979
+ delete node.html;
974
980
  body.push(node);
975
981
  } else if (this.type.label === '}') {
976
982
  return;
@@ -46,8 +46,7 @@ function add_ripple_internal_import(context) {
46
46
 
47
47
  function visit_function(node, context) {
48
48
  if (context.state.to_ts) {
49
- context.next(context.state);
50
- return;
49
+ return context.next(context.state);
51
50
  }
52
51
  const metadata = node.metadata;
53
52
  const state = context.state;
@@ -896,7 +895,11 @@ const visitors = {
896
895
  }),
897
896
  ];
898
897
 
899
- return b.function(node.id, node.params, b.block(body_statements));
898
+ return b.function(
899
+ node.id,
900
+ node.params.map((param) => context.visit(param, { ...context.state, metadata })),
901
+ b.block(body_statements),
902
+ );
900
903
  }
901
904
 
902
905
  let props = b.id('__props');
@@ -1000,8 +1003,7 @@ const visitors = {
1000
1003
 
1001
1004
  UpdateExpression(node, context) {
1002
1005
  if (context.state.to_ts) {
1003
- context.next();
1004
- return;
1006
+ return context.next();
1005
1007
  }
1006
1008
  const argument = node.argument;
1007
1009
 
@@ -1047,8 +1049,7 @@ const visitors = {
1047
1049
 
1048
1050
  ForOfStatement(node, context) {
1049
1051
  if (!is_inside_component(context)) {
1050
- context.next();
1051
- return;
1052
+ return context.next();
1052
1053
  }
1053
1054
  const is_controlled = node.is_controlled;
1054
1055
  const index = node.index;
@@ -1090,8 +1091,7 @@ const visitors = {
1090
1091
 
1091
1092
  IfStatement(node, context) {
1092
1093
  if (!is_inside_component(context)) {
1093
- context.next();
1094
- return;
1094
+ return context.next();
1095
1095
  }
1096
1096
  context.state.template.push('<!>');
1097
1097
 
@@ -1174,8 +1174,7 @@ const visitors = {
1174
1174
 
1175
1175
  TryStatement(node, context) {
1176
1176
  if (!is_inside_component(context)) {
1177
- context.next();
1178
- return;
1177
+ return context.next();
1179
1178
  }
1180
1179
  context.state.template.push('<!>');
1181
1180
 
@@ -1310,6 +1309,9 @@ function transform_ts_child(node, context) {
1310
1309
 
1311
1310
  if (node.type === 'Text') {
1312
1311
  state.init.push(b.stmt(visit(node.expression, { ...state })));
1312
+ } else if (node.type === 'Html') {
1313
+ // Do we need to do something special here?
1314
+ state.init.push(b.stmt(visit(node.expression, { ...state })));
1313
1315
  } else if (node.type === 'Element') {
1314
1316
  const type = node.id.name;
1315
1317
  const children = [];
@@ -1329,12 +1331,25 @@ function transform_ts_child(node, context) {
1329
1331
  if (attr.type === 'Attribute') {
1330
1332
  const metadata = { await: false };
1331
1333
  const name = visit(attr.name, { ...state, metadata });
1332
- const value = visit(attr.value, { ...state, metadata });
1333
- const jsx_name = b.jsx_id(name.name);
1334
- if (name.name === 'children') {
1334
+ const value =
1335
+ attr.value === null ? b.literal(true) : visit(attr.value, { ...state, metadata });
1336
+
1337
+ // Handle both regular identifiers and tracked identifiers
1338
+ let prop_name;
1339
+ if (name.type === 'Identifier') {
1340
+ prop_name = name.name;
1341
+ } else if (name.type === 'MemberExpression' && name.object.type === 'Identifier') {
1342
+ // For tracked attributes like {@count}, use the original name
1343
+ prop_name = name.object.name;
1344
+ } else {
1345
+ prop_name = attr.name.name || 'unknown';
1346
+ }
1347
+
1348
+ const jsx_name = b.jsx_id(prop_name);
1349
+ if (prop_name === 'children') {
1335
1350
  has_children_props = true;
1336
1351
  }
1337
- jsx_name.loc = name.loc;
1352
+ jsx_name.loc = attr.name.loc || name.loc;
1338
1353
 
1339
1354
  return b.jsx_attribute(jsx_name, b.jsx_expression_container(value));
1340
1355
  } else if (attr.type === 'SpreadAttribute') {
@@ -1468,7 +1483,7 @@ function transform_ts_child(node, context) {
1468
1483
 
1469
1484
  state.init.push(b.try(try_body, catch_handler, finally_block));
1470
1485
  } else if (node.type === 'Component') {
1471
- const component = visit(node, context.state);
1486
+ const component = visit(node, state);
1472
1487
 
1473
1488
  state.init.push(component);
1474
1489
  } else {
@@ -1684,7 +1699,12 @@ function transform_body(body, { visit, state }) {
1684
1699
  transform_children(body, { visit, state: body_state, root: true });
1685
1700
 
1686
1701
  if (body_state.update.length > 0) {
1687
- body_state.init.push(b.stmt(b.call('_$_.render', b.thunk(b.block(body_state.update)))));
1702
+ if (state.to_ts) {
1703
+ // In TypeScript mode, just add the update statements directly
1704
+ body_state.init.push(...body_state.update);
1705
+ } else {
1706
+ body_state.init.push(b.stmt(b.call('_$_.render', b.thunk(b.block(body_state.update)))));
1707
+ }
1688
1708
  }
1689
1709
 
1690
1710
  return [...body_state.setup, ...body_state.init, ...body_state.final];
@@ -7,14 +7,87 @@ export const mapping_data = {
7
7
  navigation: true,
8
8
  };
9
9
 
10
+ /**
11
+ * Helper to find a meaningful token boundary by looking for word boundaries,
12
+ * punctuation, or whitespace
13
+ * @param {string} text
14
+ * @param {number} start
15
+ * @param {number} direction
16
+ */
17
+ function findTokenBoundary(text, start, direction = 1) {
18
+ if (start < 0 || start >= text.length) return start;
19
+
20
+ let pos = start;
21
+ /** @param {string} c */
22
+ const isAlphaNum = (c) => /[a-zA-Z0-9_$]/.test(c);
23
+
24
+ // If we're at whitespace or punctuation, find the next meaningful character
25
+ while (pos >= 0 && pos < text.length && /\s/.test(text[pos])) {
26
+ pos += direction;
27
+ }
28
+
29
+ if (pos < 0 || pos >= text.length) return start;
30
+
31
+ // If we're in the middle of a word/identifier, find the boundary
32
+ if (isAlphaNum(text[pos])) {
33
+ if (direction > 0) {
34
+ while (pos < text.length && isAlphaNum(text[pos])) pos++;
35
+ } else {
36
+ while (pos >= 0 && isAlphaNum(text[pos])) pos--;
37
+ pos++; // Adjust back to start of token
38
+ }
39
+ } else {
40
+ // For punctuation, just move one character in the given direction
41
+ pos += direction;
42
+ }
43
+
44
+ return Math.max(0, Math.min(text.length, pos));
45
+ }
46
+
47
+ /**
48
+ * Check if source and generated content are meaningfully similar
49
+ * @param {string} sourceContent
50
+ * @param {string} generatedContent
51
+ */
52
+ function isValidMapping(sourceContent, generatedContent) {
53
+ // Remove whitespace for comparison
54
+ const cleanSource = sourceContent.replace(/\s+/g, '');
55
+ const cleanGenerated = generatedContent.replace(/\s+/g, '');
56
+
57
+ // If either is empty, skip
58
+ if (!cleanSource || !cleanGenerated) return false;
59
+
60
+ // Skip obvious template transformations that don't make sense to map
61
+ const templateTransforms = [
62
+ /^\{.*\}$/, // Curly brace expressions
63
+ /^<.*>$/, // HTML tags
64
+ /^\(\(\)\s*=>\s*\{$/, // Generated function wrappers
65
+ /^\}\)\(\)\}$/, // Generated function closures
66
+ ];
67
+
68
+ for (const transform of templateTransforms) {
69
+ if (transform.test(cleanSource) || transform.test(cleanGenerated)) {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // Check if content is similar (exact match, or generated contains source)
75
+ if (cleanSource === cleanGenerated) return true;
76
+ if (cleanGenerated.includes(cleanSource)) return true;
77
+ if (cleanSource.includes(cleanGenerated) && cleanGenerated.length > 2) return true;
78
+
79
+ return false;
80
+ }
81
+
10
82
  /**
11
83
  * Convert esrap SourceMap to Volar mappings
12
- * @param {object} source_map
84
+ * @param {{ mappings: string }} source_map
13
85
  * @param {string} source
14
86
  * @param {string} generated_code
15
87
  * @returns {object}
16
88
  */
17
89
  export function convert_source_map_to_mappings(source_map, source, generated_code) {
90
+ /** @type {Array<{sourceOffsets: number[], generatedOffsets: number[], lengths: number[], data: any}>} */
18
91
  const mappings = [];
19
92
 
20
93
  // Decode the VLQ mappings from esrap
@@ -22,6 +95,7 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
22
95
 
23
96
  let generated_offset = 0;
24
97
  const generated_lines = generated_code.split('\n');
98
+ const source_lines = source.split('\n');
25
99
 
26
100
  // Process each line of generated code
27
101
  for (let generated_line = 0; generated_line < generated_lines.length; generated_line++) {
@@ -38,7 +112,6 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
38
112
  }
39
113
 
40
114
  // Calculate source offset
41
- const source_lines = source.split('\n');
42
115
  let source_offset = 0;
43
116
  for (let i = 0; i < Math.min(source_line, source_lines.length - 1); i++) {
44
117
  source_offset += source_lines[i].length + 1; // +1 for newline
@@ -48,41 +121,125 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
48
121
  // Calculate generated offset
49
122
  const current_generated_offset = generated_offset + generated_column;
50
123
 
51
- // Determine segment length (look ahead to next mapping or end of line)
52
- const next_mapping = line_mappings[line_mappings.indexOf(mapping) + 1];
53
- let segment_length = next_mapping
54
- ? next_mapping[0] - generated_column
55
- : Math.max(1, line.length - generated_column);
56
-
57
- // Determine the actual segment content
58
- const generated_content = generated_code.substring(
59
- current_generated_offset,
60
- current_generated_offset + segment_length,
61
- );
62
- const source_content = source.substring(source_offset, source_offset + segment_length);
63
-
64
- // Skip mappings for RefAttribute syntax to avoid overlapping sourcemaps
65
- if (source_content.includes('{ref ') || source_content.match(/\{\s*ref\s+/)) {
66
- continue;
124
+ // Find meaningful token boundaries for source content
125
+ const source_token_end = findTokenBoundary(source, source_offset, 1);
126
+ const source_token_start = findTokenBoundary(source, source_offset, -1);
127
+
128
+ // Find meaningful token boundaries for generated content
129
+ const generated_token_end = findTokenBoundary(generated_code, current_generated_offset, 1);
130
+ const generated_token_start = findTokenBoundary(generated_code, current_generated_offset, -1);
131
+
132
+ // Extract potential source content (prefer forward boundary but try both directions)
133
+ let best_source_content = source.substring(source_offset, source_token_end);
134
+ let best_generated_content = generated_code.substring(current_generated_offset, generated_token_end);
135
+
136
+ // Try different segment boundaries to find the best match
137
+ const candidates = [
138
+ // Forward boundaries
139
+ {
140
+ source: source.substring(source_offset, source_token_end),
141
+ generated: generated_code.substring(current_generated_offset, generated_token_end)
142
+ },
143
+ // Backward boundaries
144
+ {
145
+ source: source.substring(source_token_start, source_offset + 1),
146
+ generated: generated_code.substring(generated_token_start, current_generated_offset + 1)
147
+ },
148
+ // Single character
149
+ {
150
+ source: source.charAt(source_offset),
151
+ generated: generated_code.charAt(current_generated_offset)
152
+ },
153
+ // Try to find exact matches in nearby content
154
+ ];
155
+
156
+ // Look for the best candidate match
157
+ let best_match = null;
158
+ for (const candidate of candidates) {
159
+ if (isValidMapping(candidate.source, candidate.generated)) {
160
+ best_match = candidate;
161
+ break;
162
+ }
67
163
  }
68
-
69
- // Fix for children mapping: when generated content is "children",
70
- // it should only map to the component name in the source, not include attributes
71
- if (generated_content === 'children') {
72
- // Look for the component name in the source content
73
- const component_name_match = source_content.match(/^(\w+)/);
74
- if (component_name_match) {
75
- const component_name = component_name_match[1];
76
- segment_length = component_name.length;
164
+
165
+ // If no good match found, try extracting identifiers/keywords
166
+ if (!best_match) {
167
+ const sourceIdMatch = source.substring(source_offset).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
168
+ const generatedIdMatch = generated_code.substring(current_generated_offset).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
169
+
170
+ if (sourceIdMatch && generatedIdMatch && sourceIdMatch[0] === generatedIdMatch[0]) {
171
+ best_match = {
172
+ source: sourceIdMatch[0],
173
+ generated: generatedIdMatch[0]
174
+ };
77
175
  }
78
176
  }
79
-
80
- mappings.push({
81
- sourceOffsets: [source_offset],
82
- generatedOffsets: [current_generated_offset],
83
- lengths: [segment_length],
84
- data: mapping_data,
85
- });
177
+
178
+ // Handle special cases for Ripple keywords that might not have generated equivalents
179
+ if (!best_match || best_match.source.length === 0) {
180
+ continue;
181
+ }
182
+
183
+ // Special handling for Ripple-specific syntax that may be omitted in generated code
184
+ const sourceAtOffset = source.substring(source_offset, source_offset + 10);
185
+ if (sourceAtOffset.includes('index ')) {
186
+ // For the 'index' keyword, create a mapping even if there's no generated equivalent
187
+ const indexMatch = sourceAtOffset.match(/index\s+/);
188
+ if (indexMatch) {
189
+ best_match = {
190
+ source: indexMatch[0].trim(),
191
+ generated: '' // Empty generated content for keywords that are transformed away
192
+ };
193
+ }
194
+ }
195
+
196
+ // Skip if we still don't have a valid source match
197
+ if (!best_match || best_match.source.length === 0) {
198
+ continue;
199
+ }
200
+
201
+ // Skip mappings for complex RefAttribute syntax to avoid overlapping sourcemaps,
202
+ // but allow simple 'ref' keyword mappings for IntelliSense
203
+ if (best_match.source.includes('{ref ') && best_match.source.length > 10) {
204
+ // Skip complex ref expressions like '{ref (node) => { ... }}'
205
+ continue;
206
+ }
207
+
208
+ // Allow simple 'ref' keyword mappings for IntelliSense
209
+ if (best_match.source.trim() === 'ref' && best_match.generated.length === 0) {
210
+ // This is just the ref keyword, allow it for syntax support
211
+ // but map it to current position since there's no generated equivalent
212
+ }
213
+
214
+ // Calculate actual offsets and lengths for the best match
215
+ let actual_source_offset, actual_generated_offset;
216
+
217
+ if (best_match.generated.length > 0) {
218
+ actual_source_offset = source.indexOf(best_match.source, source_offset - best_match.source.length);
219
+ actual_generated_offset = generated_code.indexOf(best_match.generated, current_generated_offset - best_match.generated.length);
220
+ } else {
221
+ // For keywords with no generated equivalent, use the exact source position
222
+ actual_source_offset = source_offset;
223
+ actual_generated_offset = current_generated_offset; // Map to current position in generated code
224
+ }
225
+
226
+ // Use the match we found, but fall back to original positions if indexOf fails
227
+ const final_source_offset = actual_source_offset !== -1 ? actual_source_offset : source_offset;
228
+ const final_generated_offset = actual_generated_offset !== -1 ? actual_generated_offset : current_generated_offset; // Avoid duplicate mappings by checking if we already have this exact mapping
229
+ const isDuplicate = mappings.some(existing =>
230
+ existing.sourceOffsets[0] === final_source_offset &&
231
+ existing.generatedOffsets[0] === final_generated_offset &&
232
+ existing.lengths[0] === best_match.source.length
233
+ );
234
+
235
+ if (!isDuplicate) {
236
+ mappings.push({
237
+ sourceOffsets: [final_source_offset],
238
+ generatedOffsets: [final_generated_offset],
239
+ lengths: [best_match.source.length],
240
+ data: mapping_data,
241
+ });
242
+ }
86
243
  }
87
244
 
88
245
  // Add line length + 1 for newline (except for last line)
@@ -92,6 +249,9 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
92
249
  }
93
250
  }
94
251
 
252
+ // Sort mappings by source offset for better organization
253
+ mappings.sort((a, b) => a.sourceOffsets[0] - b.sourceOffsets[0]);
254
+
95
255
  return {
96
256
  code: generated_code,
97
257
  mappings,
@@ -0,0 +1,73 @@
1
+ /** @import { Block, Derived } from '#client' */
2
+ import { safe_scope, tracked, get, derived, set } from './internal/client/runtime.js';
3
+
4
+ var init = false;
5
+
6
+ export class TrackedDate extends Date {
7
+ #time;
8
+ /** @type {Map<keyof Date, Derived>} */
9
+ #deriveds = new Map();
10
+ /** @type {Block} */
11
+ #block;
12
+
13
+ /** @param {any[]} params */
14
+ constructor(...params) {
15
+ // @ts-ignore
16
+ super(...params);
17
+
18
+ var block = this.#block = safe_scope();
19
+ this.#time = tracked(super.getTime(), block);
20
+
21
+ if (!init) this.#init();
22
+ }
23
+
24
+ #init() {
25
+ init = true;
26
+
27
+ var proto = TrackedDate.prototype;
28
+ var date_proto = Date.prototype;
29
+
30
+ var methods = /** @type {Array<keyof Date & string>} */ (
31
+ Object.getOwnPropertyNames(date_proto)
32
+ );
33
+
34
+ for (const method of methods) {
35
+ if (method.startsWith('get') || method.startsWith('to') || method === 'valueOf') {
36
+ // @ts-ignore
37
+ proto[method] = function (...args) {
38
+ // don't memoize if there are arguments
39
+ // @ts-ignore
40
+ if (args.length > 0) {
41
+ get(this.#time);
42
+ // @ts-ignore
43
+ return date_proto[method].apply(this, args);
44
+ }
45
+
46
+ var d = this.#deriveds.get(method);
47
+
48
+ if (d === undefined) {
49
+ d = derived(() => {
50
+ get(this.#time);
51
+ // @ts-ignore
52
+ return date_proto[method].apply(this, args);
53
+ }, this.#block);
54
+
55
+ this.#deriveds.set(method, d);
56
+ }
57
+
58
+ return get(d);
59
+ };
60
+ }
61
+
62
+ if (method.startsWith('set')) {
63
+ // @ts-ignore
64
+ proto[method] = function (...args) {
65
+ // @ts-ignore
66
+ var result = date_proto[method].apply(this, args);
67
+ set(this.#time, date_proto.getTime.call(this), this.#block);
68
+ return result;
69
+ };
70
+ }
71
+ }
72
+ }
73
+ }
@@ -15,38 +15,38 @@ export { jsx, jsxs, Fragment } from '../jsx-runtime.js';
15
15
  * @returns {() => void}
16
16
  */
17
17
  export function mount(component, options) {
18
- init_operations();
18
+ init_operations();
19
19
 
20
- const props = options.props || {};
21
- const target = options.target;
22
- const anchor = create_anchor();
20
+ const props = options.props || {};
21
+ const target = options.target;
22
+ const anchor = create_anchor();
23
23
 
24
- // Clear target content in case of SSR
25
- if (target.firstChild) {
26
- target.textContent = '';
27
- }
24
+ // Clear target content in case of SSR
25
+ if (target.firstChild) {
26
+ target.textContent = '';
27
+ }
28
28
 
29
- target.append(anchor);
29
+ target.append(anchor);
30
30
 
31
- const cleanup_events = handle_root_events(target);
31
+ const cleanup_events = handle_root_events(target);
32
32
 
33
- const _root = root(() => {
34
- component(anchor, props, active_block);
35
- });
33
+ const _root = root(() => {
34
+ component(anchor, props, active_block);
35
+ });
36
36
 
37
- return () => {
38
- cleanup_events();
39
- destroy_block(_root);
40
- };
37
+ return () => {
38
+ cleanup_events();
39
+ destroy_block(_root);
40
+ };
41
41
  }
42
42
 
43
43
  export { create_context as createContext } from './internal/client/context.js';
44
44
 
45
45
  export {
46
- flush_sync as flushSync,
47
- track,
48
- track_split as trackSplit,
49
- untrack,
46
+ flush_sync as flushSync,
47
+ track,
48
+ track_split as trackSplit,
49
+ untrack,
50
50
  } from './internal/client/runtime.js';
51
51
 
52
52
  export { TrackedArray } from './array.js';
@@ -57,6 +57,8 @@ export { TrackedSet } from './set.js';
57
57
 
58
58
  export { TrackedMap } from './map.js';
59
59
 
60
+ export { TrackedDate } from './date.js';
61
+
60
62
  export { keyed } from './internal/client/for.js';
61
63
 
62
64
  export { user_effect as effect } from './internal/client/blocks.js';
@@ -0,0 +1,392 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mount, flushSync, TrackedDate, track } from 'ripple';
3
+
4
+ describe('TrackedDate', () => {
5
+ let container;
6
+
7
+ function render(component) {
8
+ mount(component, {
9
+ target: container,
10
+ });
11
+ }
12
+
13
+ beforeEach(() => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ });
17
+
18
+ afterEach(() => {
19
+ document.body.removeChild(container);
20
+ container = null;
21
+ });
22
+
23
+ it('handles getTime() with reactive updates', () => {
24
+ component DateTest() {
25
+ let date = new TrackedDate(2025, 0, 1);
26
+ let time = track(() => date.getTime());
27
+
28
+ <button onClick={() => date.setFullYear(2026)}>{'Change Year'}</button>
29
+ <pre>{@time}</pre>
30
+ }
31
+
32
+ render(DateTest);
33
+
34
+ const button = container.querySelector('button');
35
+ const initialTime = container.querySelector('pre').textContent;
36
+
37
+ button.click();
38
+ flushSync();
39
+
40
+ const newTime = container.querySelector('pre').textContent;
41
+ expect(newTime).not.toBe(initialTime);
42
+ expect(parseInt(newTime)).toBeGreaterThan(parseInt(initialTime));
43
+ });
44
+
45
+ it('handles getFullYear() with reactive updates', () => {
46
+ component DateTest() {
47
+ let date = new TrackedDate(2025, 5, 15);
48
+ let year = track(() => date.getFullYear());
49
+
50
+ <button onClick={() => date.setFullYear(2030)}>{'Change Year'}</button>
51
+ <pre>{@year}</pre>
52
+ }
53
+
54
+ render(DateTest);
55
+
56
+ const button = container.querySelector('button');
57
+
58
+ expect(container.querySelector('pre').textContent).toBe('2025');
59
+
60
+ button.click();
61
+ flushSync();
62
+
63
+ expect(container.querySelector('pre').textContent).toBe('2030');
64
+ });
65
+
66
+ it('handles getMonth() with reactive updates', () => {
67
+ component DateTest() {
68
+ let date = new TrackedDate(2025, 0, 15);
69
+ let month = track(() => date.getMonth());
70
+
71
+ <button onClick={() => date.setMonth(11)}>{'Change to December'}</button>
72
+ <pre>{@month}</pre>
73
+ }
74
+
75
+ render(DateTest);
76
+
77
+ const button = container.querySelector('button');
78
+
79
+ expect(container.querySelector('pre').textContent).toBe('0');
80
+
81
+ button.click();
82
+ flushSync();
83
+
84
+ expect(container.querySelector('pre').textContent).toBe('11');
85
+ });
86
+
87
+ it('handles getDate() with reactive updates', () => {
88
+ component DateTest() {
89
+ let date = new TrackedDate(2025, 0, 1);
90
+ let day = track(() => date.getDate());
91
+
92
+ <button onClick={() => date.setDate(15)}>{'Change Day'}</button>
93
+ <pre>{@day}</pre>
94
+ }
95
+
96
+ render(DateTest);
97
+
98
+ const button = container.querySelector('button');
99
+
100
+ expect(container.querySelector('pre').textContent).toBe('1');
101
+
102
+ button.click();
103
+ flushSync();
104
+
105
+ expect(container.querySelector('pre').textContent).toBe('15');
106
+ });
107
+
108
+ it('handles getDay() with reactive updates', () => {
109
+ component DateTest() {
110
+ let date = new TrackedDate(2025, 0, 1);
111
+ let dayOfWeek = track(() => date.getDay());
112
+
113
+ <button onClick={() => date.setDate(2)}>{'Next Day'}</button>
114
+ <pre>{@dayOfWeek}</pre>
115
+ }
116
+
117
+ render(DateTest);
118
+
119
+ const button = container.querySelector('button');
120
+
121
+ expect(container.querySelector('pre').textContent).toBe('3');
122
+
123
+ button.click();
124
+ flushSync();
125
+
126
+ expect(container.querySelector('pre').textContent).toBe('4');
127
+ });
128
+
129
+ it('handles getHours() with reactive updates', () => {
130
+ component DateTest() {
131
+ let date = new TrackedDate(2025, 0, 1, 10, 30, 0);
132
+ let hours = track(() => date.getHours());
133
+
134
+ <button onClick={() => date.setHours(15)}>{'Change to 3 PM'}</button>
135
+ <pre>{@hours}</pre>
136
+ }
137
+
138
+ render(DateTest);
139
+
140
+ const button = container.querySelector('button');
141
+
142
+ expect(container.querySelector('pre').textContent).toBe('10');
143
+
144
+ button.click();
145
+ flushSync();
146
+
147
+ expect(container.querySelector('pre').textContent).toBe('15');
148
+ });
149
+
150
+ it('handles getMinutes() with reactive updates', () => {
151
+ component DateTest() {
152
+ let date = new TrackedDate(2025, 0, 1, 10, 15, 0);
153
+ let minutes = track(() => date.getMinutes());
154
+
155
+ <button onClick={() => date.setMinutes(45)}>{'Change Minutes'}</button>
156
+ <pre>{@minutes}</pre>
157
+ }
158
+
159
+ render(DateTest);
160
+
161
+ const button = container.querySelector('button');
162
+
163
+ expect(container.querySelector('pre').textContent).toBe('15');
164
+
165
+ button.click();
166
+ flushSync();
167
+
168
+ expect(container.querySelector('pre').textContent).toBe('45');
169
+ });
170
+
171
+ it('handles getSeconds() with reactive updates', () => {
172
+ component DateTest() {
173
+ let date = new TrackedDate(2025, 0, 1, 10, 15, 30);
174
+ let seconds = track(() => date.getSeconds());
175
+
176
+ <button onClick={() => date.setSeconds(45)}>{'Change Seconds'}</button>
177
+ <pre>{@seconds}</pre>
178
+ }
179
+
180
+ render(DateTest);
181
+
182
+ const button = container.querySelector('button');
183
+
184
+ expect(container.querySelector('pre').textContent).toBe('30');
185
+
186
+ button.click();
187
+ flushSync();
188
+
189
+ expect(container.querySelector('pre').textContent).toBe('45');
190
+ });
191
+
192
+ it('handles toISOString() with reactive updates', () => {
193
+ component DateTest() {
194
+ let date = new TrackedDate(2025, 0, 1, 12, 0, 0);
195
+ let isoString = track(() => date.toISOString());
196
+
197
+ <button onClick={() => date.setFullYear(2026)}>{'Change Year'}</button>
198
+ <pre>{@isoString}</pre>
199
+ }
200
+
201
+ render(DateTest);
202
+
203
+ const button = container.querySelector('button');
204
+ const initialISO = container.querySelector('pre').textContent;
205
+
206
+ expect(initialISO).toContain('2025');
207
+
208
+ button.click();
209
+ flushSync();
210
+
211
+ const newISO = container.querySelector('pre').textContent;
212
+
213
+ // Just verify that the ISO string changed after the year was updated
214
+ expect(newISO).not.toBe(initialISO);
215
+ expect(newISO.length).toBeGreaterThan(0);
216
+ });
217
+
218
+ it('handles toDateString() with reactive updates', () => {
219
+ component DateTest() {
220
+ let date = new TrackedDate(2025, 0, 1);
221
+ let dateString = track(() => date.toDateString());
222
+
223
+ <button onClick={() => date.setMonth(11)}>{'Change to December'}</button>
224
+ <pre>{@dateString}</pre>
225
+ }
226
+
227
+ render(DateTest);
228
+
229
+ const button = container.querySelector('button');
230
+ const initialDateString = container.querySelector('pre').textContent;
231
+
232
+ expect(initialDateString).toContain('Jan');
233
+
234
+ button.click();
235
+ flushSync();
236
+
237
+ const newDateString = container.querySelector('pre').textContent;
238
+ expect(newDateString).toContain('Dec');
239
+ expect(newDateString).not.toBe(initialDateString);
240
+ });
241
+
242
+ it('handles valueOf() with reactive updates', () => {
243
+ component DateTest() {
244
+ let date = new TrackedDate(2025, 0, 1);
245
+ let valueOf = track(() => date.valueOf());
246
+
247
+ <button onClick={() => date.setDate(2)}>{'Next Day'}</button>
248
+ <pre>{@valueOf}</pre>
249
+ }
250
+
251
+ render(DateTest);
252
+
253
+ const button = container.querySelector('button');
254
+ const initialValue = parseInt(container.querySelector('pre').textContent);
255
+
256
+ button.click();
257
+ flushSync();
258
+
259
+ const newValue = parseInt(container.querySelector('pre').textContent);
260
+ expect(newValue).toBeGreaterThan(initialValue);
261
+ expect(newValue - initialValue).toBe(24 * 60 * 60 * 1000);
262
+ });
263
+
264
+ it('handles multiple get methods reacting to same setTime change', () => {
265
+ component DateTest() {
266
+ let date = new TrackedDate(2025, 0, 1, 10, 30, 15);
267
+ let year = track(() => date.getFullYear());
268
+ let month = track(() => date.getMonth());
269
+ let day = track(() => date.getDate());
270
+ let hours = track(() => date.getHours());
271
+
272
+ <button onClick={() => date.setTime(new Date(2026, 5, 15, 14, 45, 30).getTime())}>{'Change All'}</button>
273
+ <div>
274
+ {'Year: '}
275
+ {@year}
276
+ </div>
277
+ <div>
278
+ {'Month: '}
279
+ {@month}
280
+ </div>
281
+ <div>
282
+ {'Day: '}
283
+ {@day}
284
+ </div>
285
+ <div>
286
+ {'Hours: '}
287
+ {@hours}
288
+ </div>
289
+ }
290
+
291
+ render(DateTest);
292
+
293
+ const button = container.querySelector('button');
294
+ const divs = container.querySelectorAll('div');
295
+
296
+ expect(divs[0].textContent).toBe('Year: 2025');
297
+ expect(divs[1].textContent).toBe('Month: 0');
298
+ expect(divs[2].textContent).toBe('Day: 1');
299
+ expect(divs[3].textContent).toBe('Hours: 10');
300
+
301
+ button.click();
302
+ flushSync();
303
+
304
+ expect(divs[0].textContent).toBe('Year: 2026');
305
+ expect(divs[1].textContent).toBe('Month: 5');
306
+ expect(divs[2].textContent).toBe('Day: 15');
307
+ expect(divs[3].textContent).toBe('Hours: 14');
308
+ });
309
+
310
+ it('handles constructor with different parameter combinations', () => {
311
+ component DateTest() {
312
+ let dateNow = new TrackedDate();
313
+ let dateFromString = new TrackedDate('2025-01-01');
314
+ let dateFromNumbers = new TrackedDate(2025, 0, 1);
315
+ let dateFromTimestamp = new TrackedDate(1735689600000);
316
+
317
+ let nowYear = track(() => dateNow.getFullYear());
318
+ let stringYear = track(() => dateFromString.getFullYear());
319
+ let numbersYear = track(() => dateFromNumbers.getFullYear());
320
+ let timestampYear = track(() => dateFromTimestamp.getFullYear());
321
+
322
+ <div>
323
+ {'Now: '}
324
+ {@nowYear}
325
+ </div>
326
+ <div>
327
+ {'String: '}
328
+ {@stringYear}
329
+ </div>
330
+ <div>
331
+ {'Numbers: '}
332
+ {@numbersYear}
333
+ </div>
334
+ <div>
335
+ {'Timestamp: '}
336
+ {@timestampYear}
337
+ </div>
338
+ }
339
+
340
+ render(DateTest);
341
+
342
+ const divs = container.querySelectorAll('div');
343
+ const currentYear = new Date().getFullYear();
344
+
345
+ expect(parseInt(divs[0].textContent.split(': ')[1])).toBe(currentYear);
346
+
347
+ // String date parsing may vary by timezone, just check it's a reasonable year
348
+ const stringYear = parseInt(divs[1].textContent.split(': ')[1]);
349
+ expect(stringYear).toBeGreaterThanOrEqual(2024);
350
+ expect(stringYear).toBeLessThanOrEqual(2025);
351
+ expect(divs[2].textContent).toBe('Numbers: 2025');
352
+
353
+ // Timestamp parsing may also vary by timezone
354
+ const timestampYear = parseInt(divs[3].textContent.split(': ')[1]);
355
+ expect(timestampYear).toBeGreaterThanOrEqual(2024);
356
+ expect(timestampYear).toBeLessThanOrEqual(2025);
357
+ });
358
+
359
+ it('handles get methods with arguments non-memoized', () => {
360
+ component DateTest() {
361
+ let date = new TrackedDate();
362
+ let localeDateString = track(() => date.toLocaleDateString('en-US'));
363
+ let localeTimeString = track(() => date.toLocaleTimeString('en-US'));
364
+
365
+ <button onClick={() => date.setFullYear(date.getFullYear() + 1)}>{'Next Year'}</button>
366
+ <div>
367
+ {'Date: '}
368
+ {@localeDateString}
369
+ </div>
370
+ <div>
371
+ {'Time: '}
372
+ {@localeTimeString}
373
+ </div>
374
+ }
375
+
376
+ render(DateTest);
377
+
378
+ const button = container.querySelector('button');
379
+ const divs = container.querySelectorAll('div');
380
+ const initialDate = divs[0].textContent;
381
+ const initialTime = divs[1].textContent;
382
+
383
+ button.click();
384
+ flushSync();
385
+
386
+ const newDate = divs[0].textContent;
387
+ const newTime = divs[1].textContent;
388
+
389
+ expect(newDate).not.toBe(initialDate);
390
+ expect(newTime).toBe(initialTime);
391
+ });
392
+ });
package/types/index.d.ts CHANGED
@@ -12,13 +12,13 @@ export declare function flushSync<T>(fn: () => T): T;
12
12
  export declare function effect(fn: (() => void) | (() => () => void)): void;
13
13
 
14
14
  export interface TrackedArrayConstructor {
15
- new <T>(...elements: T[]): TrackedArray<T>; // must be used with `new`
16
- from<T>(arrayLike: ArrayLike<T>): TrackedArray<T>;
17
- of<T>(...items: T[]): TrackedArray<T>;
18
- fromAsync<T>(iterable: AsyncIterable<T>): Promise<TrackedArray<T>>;
15
+ new <T>(...elements: T[]): TrackedArray<T>; // must be used with `new`
16
+ from<T>(arrayLike: ArrayLike<T>): TrackedArray<T>;
17
+ of<T>(...items: T[]): TrackedArray<T>;
18
+ fromAsync<T>(iterable: AsyncIterable<T>): Promise<TrackedArray<T>>;
19
19
  }
20
20
 
21
- export interface TrackedArray<T> extends Array<T> {}
21
+ export interface TrackedArray<T> extends Array<T> { }
22
22
 
23
23
  export declare const TrackedArray: TrackedArrayConstructor;
24
24
 
@@ -38,10 +38,12 @@ export declare class TrackedSet<T> extends Set<T> {
38
38
  symmetricDifference(other: TrackedSet<T> | Set<T>): TrackedSet<T>;
39
39
  union(other: TrackedSet<T> | Set<T>): TrackedSet<T>;
40
40
  toJSON(): T[];
41
+ #private;
41
42
  }
42
43
 
43
44
  export declare class TrackedMap<K, V> extends Map<K, V> {
44
45
  toJSON(): [K, V][];
46
+ #private;
45
47
  }
46
48
 
47
49
  // Compiler-injected runtime symbols (for Ripple component development)
@@ -74,17 +76,17 @@ export type Tracked<V> = { '#v': V };
74
76
  export type Props<K extends PropertyKey = any, V = unknown> = Record<K, V>;
75
77
  export type PropsWithExtras<T extends object> = Props & T & Record<string, unknown>;
76
78
  export type PropsWithChildren<T extends object = {}> =
77
- Expand<Omit<Props, 'children'> & { children: Component } & T>;
79
+ Expand<Omit<Props, 'children'> & { children: Component } & T>;
78
80
 
79
81
  type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
80
82
 
81
83
  type PickKeys<T, K extends readonly (keyof T)[]> =
82
- { [I in keyof K]: Tracked<T[K[I] & keyof T]> };
84
+ { [I in keyof K]: Tracked<T[K[I] & keyof T]> };
83
85
 
84
86
  type RestKeys<T, K extends readonly (keyof T)[]> = Expand<Omit<T, K[number]>>;
85
87
 
86
88
  type SplitResult<T extends Props, K extends readonly (keyof T)[]> =
87
- [...PickKeys<T, K>, Tracked<RestKeys<T, K>>];
89
+ [...PickKeys<T, K>, Tracked<RestKeys<T, K>>];
88
90
 
89
91
  export declare function track<V>(value?: V | (() => V), get?: (v: V) => V, set?: (next: V, prev: V) => V): Tracked<V>;
90
92
 
@@ -129,33 +131,38 @@ export function on(
129
131
  ): () => void;
130
132
 
131
133
  export type TrackedObjectShallow<T> = {
132
- [K in keyof T]: T[K] | Tracked<T[K]>;
134
+ [K in keyof T]: T[K] | Tracked<T[K]>;
133
135
  };
134
136
 
135
137
  export type TrackedObjectDeep<T> =
136
- T extends string | number | boolean | null | undefined | symbol | bigint
137
- ? T | Tracked<T>
138
- : T extends TrackedArray<infer U>
139
- ? TrackedArray<U> | Tracked<TrackedArray<U>>
140
- : T extends TrackedSet<infer U>
141
- ? TrackedSet<U> | Tracked<TrackedSet<U>>
142
- : T extends TrackedMap<infer K, infer V>
143
- ? TrackedMap<K, V> | Tracked<TrackedMap<K, V>>
144
- : T extends Array<infer U>
145
- ? Array<TrackedObjectDeep<U>> | Tracked<Array<TrackedObjectDeep<U>>>
146
- : T extends Set<infer U>
147
- ? Set<TrackedObjectDeep<U>> | Tracked<Set<TrackedObjectDeep<U>>>
148
- : T extends Map<infer K, infer V>
149
- ? Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>> |
150
- Tracked<Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>>>
151
- : T extends object
152
- ? { [K in keyof T]: TrackedObjectDeep<T[K]> | Tracked<TrackedObjectDeep<T[K]>> }
153
- : T | Tracked<T>;
138
+ T extends string | number | boolean | null | undefined | symbol | bigint
139
+ ? T | Tracked<T>
140
+ : T extends TrackedArray<infer U>
141
+ ? TrackedArray<U> | Tracked<TrackedArray<U>>
142
+ : T extends TrackedSet<infer U>
143
+ ? TrackedSet<U> | Tracked<TrackedSet<U>>
144
+ : T extends TrackedMap<infer K, infer V>
145
+ ? TrackedMap<K, V> | Tracked<TrackedMap<K, V>>
146
+ : T extends Array<infer U>
147
+ ? Array<TrackedObjectDeep<U>> | Tracked<Array<TrackedObjectDeep<U>>>
148
+ : T extends Set<infer U>
149
+ ? Set<TrackedObjectDeep<U>> | Tracked<Set<TrackedObjectDeep<U>>>
150
+ : T extends Map<infer K, infer V>
151
+ ? Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>> |
152
+ Tracked<Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>>>
153
+ : T extends object
154
+ ? { [K in keyof T]: TrackedObjectDeep<T[K]> | Tracked<TrackedObjectDeep<T[K]>> }
155
+ : T | Tracked<T>;
154
156
 
155
157
  export type TrackedObject<T extends object> = T & {};
156
158
 
157
159
  export interface TrackedObjectConstructor {
158
- new <T extends object>(obj: T): TrackedObject<T>;
160
+ new <T extends object>(obj: T): TrackedObject<T>;
159
161
  }
160
162
 
161
163
  export declare const TrackedObject: TrackedObjectConstructor;
164
+
165
+ export class SvelteDate extends Date {
166
+ constructor(...params: any[]);
167
+ #private;
168
+ }