ripple 0.2.185 → 0.2.187

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,9 +1,56 @@
1
1
  import { decode } from '@jridgewell/sourcemap-codec';
2
2
 
3
- /** @import { PostProcessingChanges, LineOffsets } from './phases/3-transform/client/index.js' */
3
+ /**
4
+ @import { PostProcessingChanges, LineOffsets } from './phases/3-transform/client/index.js';
5
+ @import * as AST from 'estree';
6
+ */
7
+
8
+ /**
9
+ @typedef {{
10
+ line: number,
11
+ column: number,
12
+ end_line: number,
13
+ end_column: number,
14
+ code: string,
15
+ metadata: {
16
+ css?: AST.Element['metadata']['css']
17
+ },
18
+ }} CodePosition
19
+
20
+ @typedef {Map<string, CodePosition[]>} CodeToGeneratedMap
21
+ @typedef {Map<string, {line: number, column: number}[]>} GeneratedToSourceMap
22
+ */
23
+
24
+ /**
25
+ * Convert byte offset to line/column
26
+ * @param {number} offset
27
+ * @param {LineOffsets} line_offsets
28
+ * @returns {{ line: number, column: number }}
29
+ */
30
+ export const offset_to_line_col = (offset, line_offsets) => {
31
+ // Binary search
32
+ let left = 0;
33
+ let right = line_offsets.length - 1;
34
+ let line = 1;
35
+
36
+ while (left <= right) {
37
+ const mid = Math.floor((left + right) / 2);
38
+ if (
39
+ offset >= line_offsets[mid] &&
40
+ (mid === line_offsets.length - 1 || offset < line_offsets[mid + 1])
41
+ ) {
42
+ line = mid + 1;
43
+ break;
44
+ } else if (offset < line_offsets[mid]) {
45
+ right = mid - 1;
46
+ } else {
47
+ left = mid + 1;
48
+ }
49
+ }
4
50
 
5
- /** @typedef {{line: number, column: number}} GeneratedPosition */
6
- /** @typedef {Map<string, GeneratedPosition[]>} SourceToGeneratedMap */
51
+ const column = offset - line_offsets[line - 1];
52
+ return { line, column };
53
+ };
7
54
 
8
55
  /**
9
56
  * Build a source-to-generated position lookup map from an esrap source map
@@ -11,11 +58,19 @@ import { decode } from '@jridgewell/sourcemap-codec';
11
58
  * @param {object} source_map - The source map object from esrap (v3 format)
12
59
  * @param {PostProcessingChanges} post_processing_changes - Optional post-processing changes to apply
13
60
  * @param {LineOffsets} line_offsets - Pre-computed line offsets array
14
- * @returns {SourceToGeneratedMap} Map from "sourceLine:sourceColumn" to array of generated positions
61
+ * @param {string} generated_code - The final generated code (after post-processing)
62
+ * @returns {[CodeToGeneratedMap, GeneratedToSourceMap]} Tuple of [source-to-generated map, generated-to-source map]
15
63
  */
16
- export function build_source_to_generated_map(source_map, post_processing_changes, line_offsets) {
17
- /** @type {SourceToGeneratedMap} */
64
+ export function build_src_to_gen_map(
65
+ source_map,
66
+ post_processing_changes,
67
+ line_offsets,
68
+ generated_code,
69
+ ) {
70
+ /** @type {CodeToGeneratedMap} */
18
71
  const map = new Map();
72
+ /** @type {GeneratedToSourceMap} */
73
+ const reverse_map = new Map();
19
74
 
20
75
  // Decode the VLQ-encoded mappings string
21
76
  // @ts-ignore
@@ -31,101 +86,120 @@ export function build_source_to_generated_map(source_map, post_processing_change
31
86
  return line_offsets[line - 1] + column;
32
87
  };
33
88
 
34
- /**
35
- * Convert byte offset to line/column
36
- * @param {number} offset
37
- * @returns {{ line: number, column: number }}
38
- */
39
- const offset_to_line_col = (offset) => {
40
- // Binary search
41
- let left = 0;
42
- let right = line_offsets.length - 1;
43
- let line = 1;
44
-
45
- while (left <= right) {
46
- const mid = Math.floor((left + right) / 2);
47
- if (
48
- offset >= line_offsets[mid] &&
49
- (mid === line_offsets.length - 1 || offset < line_offsets[mid + 1])
50
- ) {
51
- line = mid + 1;
52
- break;
53
- } else if (offset < line_offsets[mid]) {
54
- right = mid - 1;
55
- } else {
56
- left = mid + 1;
57
- }
58
- }
59
-
60
- const column = offset - line_offsets[line - 1];
61
- return { line, column };
62
- };
89
+ // Apply post-processing adjustments to all segments first
90
+ /** @type {Array<Array<{line: number, column: number, sourceLine: number, sourceColumn: number}>>} */
91
+ const adjusted_segments = [];
63
92
 
64
- // decoded is an array of lines, each line is an array of segments
65
- // Each segment is [generatedColumn, sourceIndex, sourceLine, sourceColumn, nameIndex?]
66
93
  for (let generated_line = 0; generated_line < decoded.length; generated_line++) {
67
94
  const line = decoded[generated_line];
95
+ adjusted_segments[generated_line] = [];
68
96
 
69
97
  for (const segment of line) {
70
98
  if (segment.length >= 4) {
71
- let generated_column = segment[0];
72
- // just keeping this unused for context
73
- // const source_index = segment[1]; // which source file (we only have one)
74
- const source_line = /** @type {number} */ (segment[2]);
75
- const source_column = /** @type {number} */ (segment[3]);
76
-
77
- // Apply post-processing adjustments if needed
78
99
  let adjusted_line = generated_line + 1;
79
- let adjusted_column = generated_column;
100
+ let adjusted_column = segment[0];
80
101
 
81
102
  if (post_processing_changes) {
82
103
  const line_change = post_processing_changes.get(adjusted_line);
83
104
 
84
105
  if (line_change) {
85
- // Check if this position is affected by the change
86
106
  const pos_offset = line_col_to_byte_offset(adjusted_line, adjusted_column);
87
107
 
88
108
  if (pos_offset >= line_change.offset) {
89
- // Position is on or after the change - apply delta
90
109
  const adjusted_offset = pos_offset + line_change.delta;
91
- const adjusted_pos = offset_to_line_col(adjusted_offset);
110
+ const adjusted_pos = offset_to_line_col(adjusted_offset, line_offsets);
92
111
  adjusted_line = adjusted_pos.line;
93
112
  adjusted_column = adjusted_pos.column;
94
113
  }
95
114
  }
96
115
  }
97
116
 
98
- // Create key from source position (1-indexed line, 0-indexed column)
99
- const key = `${source_line + 1}:${source_column}`;
117
+ adjusted_segments[generated_line].push({
118
+ line: adjusted_line,
119
+ column: adjusted_column,
120
+ sourceLine: /** @type {number} */ (segment[2]),
121
+ sourceColumn: /** @type {number} */ (segment[3]),
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ // Now build the map using adjusted positions
128
+ for (let line_idx = 0; line_idx < adjusted_segments.length; line_idx++) {
129
+ const line_segments = adjusted_segments[line_idx];
130
+
131
+ for (let seg_idx = 0; seg_idx < line_segments.length; seg_idx++) {
132
+ const segment = line_segments[seg_idx];
133
+ const line = segment.line;
134
+ const column = segment.column;
135
+
136
+ // Determine end position using next segment
137
+ let end_line = line;
138
+ let end_column = column;
139
+
140
+ // Look for next segment to determine end position
141
+ if (seg_idx + 1 < line_segments.length) {
142
+ // Next segment on same line
143
+ const next_segment = line_segments[seg_idx + 1];
144
+ end_line = next_segment.line;
145
+ end_column = next_segment.column;
146
+ } else if (
147
+ line_idx + 1 < adjusted_segments.length &&
148
+ adjusted_segments[line_idx + 1].length > 0
149
+ ) {
150
+ // Look at first segment of next line
151
+ const next_segment = adjusted_segments[line_idx + 1][0];
152
+ end_line = next_segment.line;
153
+ end_column = next_segment.column;
154
+ }
155
+
156
+ // Extract code snippet
157
+ const start_offset = line_col_to_byte_offset(line, column);
158
+ const end_offset = line_col_to_byte_offset(end_line, end_column);
159
+ const code_snippet = generated_code.slice(start_offset, end_offset);
100
160
 
101
- // Store adjusted generated position
102
- const gen_pos = { line: adjusted_line, column: adjusted_column };
161
+ // Create key from source position (1-indexed line, 0-indexed column)
162
+ segment.sourceLine += 1;
163
+ const key = `${segment.sourceLine}:${segment.sourceColumn}`;
103
164
 
104
- if (!map.has(key)) {
105
- map.set(key, []);
106
- }
107
- /** @type {GeneratedPosition[]} */ (map.get(key)).push(gen_pos);
165
+ // Store adjusted generated position with code snippet
166
+ const gen_pos = { line, column, end_line, end_column, code: code_snippet, metadata: {} };
167
+
168
+ if (!map.has(key)) {
169
+ map.set(key, []);
170
+ }
171
+ /** @type {CodePosition[]} */ (map.get(key)).push(gen_pos);
172
+
173
+ // Store reverse mapping (generated to source)
174
+ const gen_key = `${gen_pos.line}:${gen_pos.column}`;
175
+
176
+ if (!reverse_map.has(gen_key)) {
177
+ reverse_map.set(gen_key, []);
108
178
  }
179
+ reverse_map.get(gen_key)?.push({
180
+ line: segment.sourceLine,
181
+ column: segment.sourceColumn,
182
+ });
109
183
  }
110
184
  }
111
185
 
112
- return map;
186
+ return [map, reverse_map];
113
187
  }
114
188
 
115
189
  /**
116
190
  * Look up generated position for a given source position
117
- * @param {number} source_line - 1-based line number in source
118
- * @param {number} source_column - 0-based column number in source
119
- * @param {SourceToGeneratedMap} source_to_gen_map - Lookup map
120
- * @returns {{line: number, column: number}} Generated position
191
+ * @param {number} src_line - 1-based line number in source
192
+ * @param {number} src_column - 0-based column number in source
193
+ * @param {CodeToGeneratedMap} src_to_gen_map - Lookup map
194
+ * @returns {CodePosition} Generated position
121
195
  */
122
- export function get_generated_position(source_line, source_column, source_to_gen_map) {
123
- const key = `${source_line}:${source_column}`;
124
- const positions = source_to_gen_map.get(key);
196
+ export function get_generated_position(src_line, src_column, src_to_gen_map) {
197
+ const key = `${src_line}:${src_column}`;
198
+ const positions = src_to_gen_map.get(key);
125
199
 
126
200
  if (!positions || positions.length === 0) {
127
201
  // No mapping found in source map - this shouldn't happen since all tokens should have mappings
128
- throw new Error(`No source map entry for position "${source_line}:${source_column}"`);
202
+ throw new Error(`No source map entry for position "${src_line}:${src_column}"`);
129
203
  }
130
204
 
131
205
  // If multiple generated positions map to same source, return the first
@@ -226,6 +226,28 @@ declare module 'estree' {
226
226
  loc: SourceLocation;
227
227
  metadata: BaseNodeMetaData & {
228
228
  ts_name?: string;
229
+ // for <style> tag
230
+ styleScopeHash?: string;
231
+ // for elements with scoped style classes
232
+ css?: {
233
+ scopedClasses: Map<
234
+ string,
235
+ {
236
+ start: number;
237
+ end: number;
238
+ selector: CSS.ClassSelector;
239
+ }
240
+ >;
241
+ topScopedClasses: Map<
242
+ string,
243
+ {
244
+ start: number;
245
+ end: number;
246
+ selector: CSS.ClassSelector;
247
+ }
248
+ >;
249
+ hash: string;
250
+ };
229
251
  };
230
252
 
231
253
  // currently only for <style> and <script> tags
@@ -56,6 +56,12 @@ declare module 'zimmerframe' {
56
56
  state: any,
57
57
  visitors: RippleCompiler.Visitors<AST.Node, any>,
58
58
  ): AST.Node;
59
+
60
+ export function walk(
61
+ node: AST.CSS.Node,
62
+ state: any,
63
+ visitors: RippleCompiler.Visitors<(AST.CSS.Node), any>,
64
+ ): AST.CSS.Node;
59
65
  }
60
66
 
61
67
  export namespace Parse {
@@ -1,5 +1,7 @@
1
- /** @import { CommonContext, NameSpace, ScopeInterface } from '#compiler' */
2
- /** @import * as AST from 'estree' */
1
+ /**
2
+ @import * as AST from 'estree';
3
+ @import { CommonContext, NameSpace, ScopeInterface } from '#compiler';
4
+ */
3
5
 
4
6
  import { build_assignment_value, extract_paths } from '../utils/ast.js';
5
7
  import * as b from '../utils/builders.js';
@@ -2,32 +2,76 @@
2
2
 
3
3
  import { branch, destroy_block, render } from './blocks.js';
4
4
  import { SWITCH_BLOCK } from './constants.js';
5
+ import { next_sibling } from './operations.js';
6
+ import { append } from './template.js';
5
7
 
6
8
  /**
7
- * @param {Node} node
8
- * @param {() => ((anchor: Node) => void)} fn
9
+ * Moves a block's DOM nodes to before an anchor node
10
+ * @param {Block} block
11
+ * @param {ChildNode} anchor
9
12
  * @returns {void}
10
13
  */
11
- export function switch_block(node, fn) {
12
- var anchor = node;
13
- /** @type {any} */
14
- var current_branch = null;
15
- /** @type {Block | null} */
16
- var b = null;
14
+ function move(block, anchor) {
15
+ var node = block.s.start;
16
+ var end = block.s.end;
17
+ /** @type {Node | null} */
18
+ var sibling;
19
+
20
+ while (node !== null) {
21
+ if (node === end) {
22
+ append(anchor, node);
23
+ break;
24
+ }
25
+ sibling = next_sibling(node);
26
+ append(anchor, node);
27
+ node = sibling;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {ChildNode} anchor
33
+ * @param {() => ((anchor: ChildNode) => void)[] | null} fn
34
+ * @returns {void}
35
+ */
36
+ export function switch_block(anchor, fn) {
37
+ /** @type {((anchor: ChildNode) => void)[]} */
38
+ var prev = [];
39
+ /** @type {Map<(anchor: ChildNode) => void, Block>} */
40
+ var blocks = new Map();
17
41
 
18
42
  render(
19
43
  () => {
20
- const branch_fn = fn() ?? null;
21
- if (current_branch === branch_fn) return;
22
- current_branch = branch_fn;
44
+ var funcs = fn();
45
+ let same = prev.length === funcs?.length || false;
23
46
 
24
- if (b !== null) {
25
- destroy_block(b);
26
- b = null;
47
+ for (var i = 0; i < prev.length; i++) {
48
+ var p = prev[i];
49
+
50
+ if (!funcs || funcs.indexOf(p) === -1) {
51
+ same = false;
52
+ destroy_block(/** @type {Block} */ (blocks.get(p)));
53
+ blocks.delete(p);
54
+ }
27
55
  }
28
56
 
29
- if (branch_fn !== null) {
30
- b = branch(() => branch_fn(anchor));
57
+ prev = funcs ?? [];
58
+
59
+ if (same || !funcs) {
60
+ return;
61
+ }
62
+
63
+ for (var i = 0; i < funcs.length; i++) {
64
+ var n = funcs[i];
65
+ var b = blocks.get(n);
66
+ if (b) {
67
+ move(b, anchor);
68
+ continue;
69
+ }
70
+
71
+ blocks.set(
72
+ n,
73
+ branch(() => n(anchor)),
74
+ );
31
75
  }
32
76
  },
33
77
  null,
@@ -89,7 +89,7 @@ describe('switch statements', () => {
89
89
  expect(container.querySelector('div').textContent).toBe('Default for y');
90
90
  });
91
91
 
92
- it('renders switch using fallthrough without recreating DOM unnecessarily', () => {
92
+ it('renders switch using empty case fall-through', () => {
93
93
  component App() {
94
94
  let value = track('a');
95
95
 
@@ -131,7 +131,7 @@ describe('switch statements', () => {
131
131
  expect(container.querySelector('div').textContent).toBe('DOM check');
132
132
  });
133
133
 
134
- it('renders switch with template content and JS logic', () => {
134
+ it('renders switch with template content and reacts to tracked changes', () => {
135
135
  component App() {
136
136
  let status = track('active');
137
137
  let message = track('');
@@ -180,4 +180,140 @@ describe('switch statements', () => {
180
180
  expect(container.querySelector('div').textContent).toBe('Status: An error occurred.');
181
181
  expect(container.querySelector('.error')).toBeTruthy();
182
182
  });
183
+
184
+ it(
185
+ 'renders switch with multiple non-empty fall-through cases and reacts to tracked changes without recreating DOM unnecessarily',
186
+ () => {
187
+ component App() {
188
+ let status = track(0);
189
+ <div>
190
+ switch (@status) {
191
+ case -1:
192
+ case 0:
193
+ <p>{' Idle'}</p>
194
+ case 1:
195
+ <p>{' Loading'}</p>
196
+ case 2:
197
+ <p>{' Success'}</p>
198
+ break;
199
+ default:
200
+ <p>{' Unknown status'}</p>
201
+ <p>{' Unknown 2'}</p>
202
+ case 3:
203
+ <p>{' Error'}</p>
204
+ <p>{' Error 2'}</p>
205
+ <p>{' Error 3'}</p>
206
+ break;
207
+ }
208
+ </div>
209
+ <button
210
+ onClick={() => {
211
+ @status = (@status + 1) % 5;
212
+ }}
213
+ >
214
+ {'Next Status'}
215
+ </button>
216
+ }
217
+
218
+ render(App);
219
+ const button = container.querySelector('button');
220
+
221
+ expect(container.querySelector('div').textContent).toBe(' Idle Loading Success');
222
+
223
+ button.click();
224
+ flushSync();
225
+
226
+ expect(container.querySelector('div').textContent).toBe(' Loading Success');
227
+
228
+ button.click();
229
+ flushSync();
230
+
231
+ expect(container.querySelector('div').textContent).toBe(' Success');
232
+
233
+ button.click();
234
+ flushSync();
235
+
236
+ expect(container.querySelector('div').textContent).toBe(' Error Error 2 Error 3');
237
+
238
+ button.click();
239
+ flushSync();
240
+
241
+ expect(container.querySelector('div').textContent).toBe(
242
+ ' Unknown status Unknown 2 Error Error 2 Error 3',
243
+ );
244
+
245
+ button.click();
246
+ flushSync();
247
+
248
+ expect(container.querySelector('div').textContent).toBe(' Idle Loading Success');
249
+ },
250
+ );
251
+
252
+ it(
253
+ 'renders a fall-through default in the middle of switch cases and reacts to changes without recreating DOM unnecessarily',
254
+ () => {
255
+ component App() {
256
+ let value = track('x');
257
+
258
+ <button onClick={() => (@value = 'a')}>{'Set A'}</button>
259
+ <button onClick={() => (@value = 'b')}>{'Set B'}</button>
260
+ <button onClick={() => (@value = 'c')}>{'Set C'}</button>
261
+ <button onClick={() => (@value = 'x')}>{'Set X'}</button>
262
+
263
+ <div>
264
+ switch (@value) {
265
+ case 'a':
266
+ <div>{' Case A'}</div>
267
+ break;
268
+ // NOTE: This should be the default in the middle of the cases
269
+ // However, jsdom (and other node-based dom libs) has a bug
270
+ // that breaks out of the switch even if the default doesn't have a break
271
+ // The browser works correctly.
272
+ // So, we're just using a defined case in the middle to simulate default.
273
+ case 'x':
274
+ <div>{' Default Case for ' + @value}</div>
275
+ case 'b':
276
+ <div>{' Case B'}</div>
277
+ break;
278
+ case 'c':
279
+ <div>{' Case C'}</div>
280
+ }
281
+ </div>
282
+ }
283
+
284
+ render(App);
285
+ const [buttonA, buttonB, buttonC, buttonX] = container.querySelectorAll('button');
286
+
287
+ expect(container.querySelector('div').textContent).toBe(' Default Case for x Case B');
288
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(2);
289
+
290
+ buttonA.click();
291
+ flushSync();
292
+
293
+ expect(container.querySelector('div').textContent).toBe(' Case A');
294
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(1);
295
+
296
+ buttonC.click();
297
+ flushSync();
298
+
299
+ expect(container.querySelector('div').textContent).toBe(' Case C');
300
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(1);
301
+
302
+ buttonB.click();
303
+ flushSync();
304
+
305
+ const bDiv = container.querySelector('div').querySelectorAll('div')[0];
306
+ expect(bDiv.textContent).toBe(' Case B');
307
+ bDiv.id = 'b';
308
+
309
+ buttonX.click();
310
+ flushSync();
311
+
312
+ // order should be correct with the previously rendered B element
313
+ expect(container.querySelector('div').textContent).toBe(' Default Case for x Case B');
314
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(2);
315
+ // the previously rendered div should be preserved
316
+ expect(container.querySelector('div').querySelectorAll('div')[1].id).toBe('b');
317
+ },
318
+ );
183
319
  });
@@ -25,7 +25,7 @@ describe('SSR: switch statements', () => {
25
25
  expect(body).toBe('<div>Case B</div>');
26
26
  });
27
27
 
28
- it('renders switch using fallthrough case', async () => {
28
+ it('renders a fall-through with an empty switch case', async () => {
29
29
  component App() {
30
30
  let value = 'b';
31
31
 
@@ -45,4 +45,47 @@ describe('SSR: switch statements', () => {
45
45
  const { body } = await render(App);
46
46
  expect(body).toBe('<div>Case B or C</div>');
47
47
  });
48
+
49
+ it('renders a fall-through with a case that has elements', async () => {
50
+ component App() {
51
+ let value = 'a';
52
+
53
+ switch (value) {
54
+ case 'a':
55
+ <div>{'Case A'}</div>
56
+ case 'b':
57
+ <div>{'Case B'}</div>
58
+ case 'c':
59
+ <div>{'Case C'}</div>
60
+ break;
61
+ default:
62
+ <div>{'Default Case'}</div>
63
+ }
64
+ }
65
+
66
+ const { body } = await render(App);
67
+ expect(body).toBe('<div>Case A</div><div>Case B</div><div>Case C</div>');
68
+ });
69
+
70
+ it('renders a fall-through with a default case in the middle', async () => {
71
+ component App() {
72
+ let value = 'x';
73
+
74
+ switch (value) {
75
+ case 'a':
76
+ <div>{'Case A'}</div>
77
+ default:
78
+ <div>{'Default Case'}</div>
79
+ case 'b':
80
+ <div>{'Case B'}</div>
81
+ break;
82
+ case 'c':
83
+ <div>{'Case C'}</div>
84
+ break;
85
+ }
86
+ }
87
+
88
+ const { body } = await render(App);
89
+ expect(body).toBe('<div>Default Case</div><div>Case B</div>');
90
+ });
48
91
  });