ripple 0.2.199 → 0.2.201

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.
Files changed (55) hide show
  1. package/package.json +5 -4
  2. package/src/compiler/index.d.ts +1 -5
  3. package/src/compiler/phases/1-parse/index.js +145 -11
  4. package/src/compiler/phases/2-analyze/index.js +24 -8
  5. package/src/compiler/phases/2-analyze/prune.js +5 -3
  6. package/src/compiler/phases/3-transform/client/index.js +312 -165
  7. package/src/compiler/phases/3-transform/segments.js +220 -70
  8. package/src/compiler/phases/3-transform/server/index.js +227 -77
  9. package/src/compiler/source-map-utils.js +74 -10
  10. package/src/compiler/types/index.d.ts +63 -21
  11. package/src/compiler/types/parse.d.ts +3 -1
  12. package/src/compiler/utils.js +34 -0
  13. package/src/helpers.d.ts +5 -0
  14. package/src/runtime/index-server.js +27 -47
  15. package/src/runtime/internal/client/composite.js +5 -0
  16. package/src/runtime/internal/client/events.js +1 -9
  17. package/src/runtime/internal/client/for.js +6 -4
  18. package/src/runtime/internal/client/hydration.js +2 -2
  19. package/src/runtime/internal/client/index.js +1 -1
  20. package/src/runtime/internal/client/operations.js +4 -4
  21. package/src/runtime/internal/client/render.js +0 -2
  22. package/src/runtime/internal/client/template.js +9 -1
  23. package/src/runtime/internal/client/types.d.ts +18 -0
  24. package/src/runtime/internal/client/utils.js +1 -1
  25. package/src/runtime/internal/server/index.js +106 -3
  26. package/src/utils/builders.js +25 -5
  27. package/tests/client/basic/basic.attributes.test.ripple +1 -1
  28. package/tests/client/basic/basic.components.test.ripple +47 -0
  29. package/tests/client/basic/basic.rendering.test.ripple +1 -1
  30. package/tests/client/composite/composite.props.test.ripple +49 -4
  31. package/tests/client/dynamic-elements.test.ripple +44 -0
  32. package/tests/client/switch.test.ripple +40 -0
  33. package/tests/client/tsconfig.json +11 -0
  34. package/tests/client.d.ts +5 -22
  35. package/tests/common.d.ts +24 -0
  36. package/tests/hydration/compiled/server/basic.js +109 -24
  37. package/tests/hydration/compiled/server/events.js +161 -72
  38. package/tests/hydration/compiled/server/for.js +202 -102
  39. package/tests/hydration/compiled/server/if.js +130 -50
  40. package/tests/hydration/compiled/server/reactivity.js +51 -12
  41. package/tests/server/__snapshots__/compiler.test.ripple.snap +11 -4
  42. package/tests/server/basic.attributes.test.ripple +459 -0
  43. package/tests/server/basic.components.test.ripple +237 -0
  44. package/tests/server/basic.test.ripple +25 -0
  45. package/tests/server/compiler.test.ripple +2 -3
  46. package/tests/server/composite.props.test.ripple +161 -0
  47. package/tests/server/dynamic-elements.test.ripple +438 -0
  48. package/tests/server/head.test.ripple +102 -0
  49. package/tests/server/switch.test.ripple +40 -0
  50. package/tests/server/tsconfig.json +11 -0
  51. package/tests/server.d.ts +7 -0
  52. package/tests/setup-client.js +6 -2
  53. package/tests/setup-server.js +16 -0
  54. package/types/index.d.ts +2 -2
  55. package/types/server.d.ts +4 -3
@@ -4,8 +4,8 @@
4
4
  */
5
5
 
6
6
  import { Readable } from 'stream';
7
- import { DERIVED, UNINITIALIZED } from '../client/constants.js';
8
- import { is_tracked_object } from '../client/utils.js';
7
+ import { DERIVED, UNINITIALIZED, TRACKED } from '../client/constants.js';
8
+ import { is_tracked_object, get_descriptor, define_property, is_array } from '../client/utils.js';
9
9
  import { escape } from '../../../utils/escaping.js';
10
10
  import { is_boolean_attribute } from '../../../compiler/utils.js';
11
11
  import { clsx } from 'clsx';
@@ -180,6 +180,8 @@ class Output {
180
180
  #parent = null;
181
181
  /** @type {import('stream').Readable | null} */
182
182
  #stream = null;
183
+ /** @type {null | 'head'} */
184
+ target = null;
183
185
 
184
186
  /**
185
187
  * @param {Output | null} parent
@@ -195,6 +197,11 @@ class Output {
195
197
  * @returns {void}
196
198
  */
197
199
  push(str) {
200
+ if (this.target === 'head') {
201
+ this.head += str;
202
+ return;
203
+ }
204
+
198
205
  if (this.#stream) {
199
206
  this.#stream.push(str);
200
207
  } else {
@@ -476,7 +483,7 @@ export function spread_attrs(attrs, css_hash) {
476
483
  }
477
484
 
478
485
  if (name === 'class' && css_hash) {
479
- value = value == null ? css_hash : [value, css_hash];
486
+ value = value == null || value === css_hash ? css_hash : [value, css_hash];
480
487
  }
481
488
 
482
489
  attr_str += attr(name, value, is_boolean_attribute(name));
@@ -484,3 +491,99 @@ export function spread_attrs(attrs, css_hash) {
484
491
 
485
492
  return attr_str;
486
493
  }
494
+
495
+ var empty_get_set = { get: undefined, set: undefined };
496
+
497
+ /**
498
+ * @param {any} v
499
+ * @param {(value: any) => any} [get]
500
+ * @param {(next: any, prev: any) => any} [set]
501
+ * @returns {Tracked}
502
+ */
503
+ function tracked(v, get, set) {
504
+ return {
505
+ a: get || set ? { get, set } : empty_get_set,
506
+ c: 0,
507
+ f: TRACKED,
508
+ v,
509
+ };
510
+ }
511
+
512
+ /**
513
+ * @param {any} v
514
+ * @param {(value: any) => any} [get]
515
+ * @param {(next: any, prev: any) => any} [set]
516
+ * @returns {Tracked | Derived}
517
+ */
518
+ export function track(v, get, set) {
519
+ var is_tracked = is_tracked_object(v);
520
+
521
+ if (is_tracked) {
522
+ return v;
523
+ }
524
+
525
+ if (typeof v === 'function') {
526
+ return {
527
+ a: get || set ? { get, set } : empty_get_set,
528
+ c: 0,
529
+ co: active_component,
530
+ d: null,
531
+ f: TRACKED | DERIVED,
532
+ fn: v,
533
+ v: UNINITIALIZED,
534
+ };
535
+ }
536
+
537
+ return tracked(v, get, set);
538
+ }
539
+
540
+ /**
541
+ * @param {Record<string|symbol, any>} v
542
+ * @param {(symbol | string)[]} l
543
+ * @returns {Tracked[]}
544
+ */
545
+ export function track_split(v, l) {
546
+ var is_tracked = is_tracked_object(v);
547
+
548
+ if (is_tracked || typeof v !== 'object' || v === null || is_array(v)) {
549
+ throw new TypeError('Invalid value: expected a non-tracked object');
550
+ }
551
+
552
+ /** @type {Tracked[]} */
553
+ var out = [];
554
+ /** @type {Record<string|symbol, any>} */
555
+ var rest = {};
556
+ /** @type {Record<PropertyKey, 1>} */
557
+ var done = {};
558
+ var props = Reflect.ownKeys(v);
559
+
560
+ for (let i = 0, key, t; i < l.length; i++) {
561
+ key = l[i];
562
+
563
+ if (props.includes(key)) {
564
+ if (is_tracked_object(v[key])) {
565
+ t = v[key];
566
+ } else {
567
+ t = tracked(undefined);
568
+ t = define_property(t, '__v', /** @type {PropertyDescriptor} */ (get_descriptor(v, key)));
569
+ }
570
+ } else {
571
+ t = tracked(undefined);
572
+ }
573
+
574
+ out[i] = t;
575
+ done[key] = 1;
576
+ }
577
+
578
+ for (let i = 0, key; i < props.length; i++) {
579
+ key = props[i];
580
+ if (done[key]) {
581
+ continue;
582
+ }
583
+ define_property(rest, key, /** @type {PropertyDescriptor} */ (get_descriptor(v, key)));
584
+ }
585
+
586
+ out.push(tracked(rest));
587
+
588
+ return out;
589
+ }
@@ -8,13 +8,22 @@ import { sanitize_template_string } from './sanitize_template_string.js';
8
8
  * @template {AST.Node} T
9
9
  * @param {T} node
10
10
  * @param {AST.NodeWithLocation | undefined} loc_info
11
+ * @param {boolean} is_deep_copy
11
12
  * @returns {T}
12
13
  */
13
- function set_location(node, loc_info) {
14
+ export function set_location(node, loc_info, is_deep_copy = false) {
14
15
  if (loc_info) {
15
16
  node.start = loc_info.start;
16
17
  node.end = loc_info.end;
17
- node.loc = loc_info.loc;
18
+
19
+ if (is_deep_copy) {
20
+ node.loc = {
21
+ start: { ...loc_info.loc.start },
22
+ end: { ...loc_info.loc.end },
23
+ };
24
+ } else {
25
+ node.loc = loc_info.loc;
26
+ }
18
27
  }
19
28
 
20
29
  return node;
@@ -173,7 +182,7 @@ export function call(callee, ...args) {
173
182
 
174
183
  /**
175
184
  * @param {string | AST.Expression} callee
176
- * @param {...AST.Expression} args
185
+ * @param {...(AST.Expression | AST.SpreadElement | false | undefined)} args
177
186
  * @returns {AST.ChainExpression}
178
187
  */
179
188
  export function maybe_call(callee, ...args) {
@@ -362,14 +371,25 @@ export function literal(value, loc_info) {
362
371
  * @param {string | AST.Expression | AST.PrivateIdentifier} property
363
372
  * @param {boolean} computed
364
373
  * @param {boolean} optional
374
+ * @param {AST.NodeWithLocation} [loc_info]
365
375
  * @returns {AST.MemberExpression}
366
376
  */
367
- export function member(object, property, computed = false, optional = false) {
377
+ export function member(object, property, computed = false, optional = false, loc_info) {
368
378
  if (typeof property === 'string') {
369
379
  property = id(property);
370
380
  }
371
381
 
372
- return { type: 'MemberExpression', object, property, computed, optional, metadata: { path: [] } };
382
+ /** @type {AST.MemberExpression} */
383
+ const node = {
384
+ type: 'MemberExpression',
385
+ object,
386
+ property,
387
+ computed,
388
+ optional,
389
+ metadata: { path: [] },
390
+ };
391
+
392
+ return set_location(node, loc_info);
373
393
  }
374
394
 
375
395
  /**
@@ -139,7 +139,7 @@ describe('basic client > attribute rendering', () => {
139
139
  expect(div.classList.contains('active')).toBe(false);
140
140
  });
141
141
 
142
- it('applies scoped ripple classs to multiple elements with dynamic class expressions', () => {
142
+ it('applies scoped ripple class to multiple elements with dynamic class expressions', () => {
143
143
  component Basic() {
144
144
  let selected = track(1);
145
145
 
@@ -26,6 +26,53 @@ describe('basic client > components & composition', () => {
26
26
  expect(paragraph.textContent).toBe('Card content here');
27
27
  });
28
28
 
29
+ it('does not render a falsy component call', () => {
30
+ component Card(props: PropsWithChildren<{}>) {
31
+ <div class="card">
32
+ <props.children />
33
+ </div>
34
+ }
35
+
36
+ component Basic() {
37
+ <Card>
38
+ component test() {
39
+ <p>{'Card content here'}</p>
40
+ }
41
+ </Card>
42
+ }
43
+
44
+ render(Basic);
45
+
46
+ const card = container.querySelector('.card');
47
+ const paragraph = card.querySelector('p');
48
+
49
+ expect(card).toBeTruthy();
50
+ expect(paragraph).toBeFalsy();
51
+ });
52
+
53
+ it('renders a component when children is set a component prop', () => {
54
+ component Card(props: PropsWithChildren<{}>) {
55
+ <div class="card">
56
+ <props.children />
57
+ </div>
58
+ }
59
+
60
+ component Basic() {
61
+ component children() {
62
+ <p>{'Card content here'}</p>
63
+ }
64
+ <Card {children} />
65
+ }
66
+
67
+ render(Basic);
68
+
69
+ const card = container.querySelector('.card');
70
+ const paragraph = card.querySelector('p');
71
+
72
+ expect(card).toBeTruthy();
73
+ expect(paragraph.textContent).toBe('Card content here');
74
+ });
75
+
29
76
  it('renders with nested components and prop passing', () => {
30
77
  component Button(props: PropsWithExtras<{
31
78
  variant: string;
@@ -90,7 +90,7 @@ describe('basic client > rendering & text', () => {
90
90
 
91
91
  it('renders simple JS expression logic correctly', () => {
92
92
  component Example() {
93
- let test = {};
93
+ let test: Record<number, string> = {};
94
94
  let counter = 0;
95
95
  test[counter++] = 'Test';
96
96
 
@@ -1,4 +1,5 @@
1
- import { track, trackSplit, effect, flushSync, type Props } from 'ripple';
1
+ import type { Tracked, Props } from 'ripple';
2
+ import { track, trackSplit, effect, flushSync } from 'ripple';
2
3
 
3
4
  describe('composite > props', () => {
4
5
  it('correctly handles default prop values', () => {
@@ -56,7 +57,7 @@ describe('composite > props', () => {
56
57
  });
57
58
 
58
59
  it('correctly handles no props #2', () => {
59
- component Child({ foo }) {
60
+ component Child({ foo }: { foo?: number }) {
60
61
  <div>{foo}</div>
61
62
  }
62
63
 
@@ -76,7 +77,7 @@ describe('composite > props', () => {
76
77
  it('mutating a tracked value prop should work as intended', () => {
77
78
  const logs: number[] = [];
78
79
 
79
- component Counter({ count }) {
80
+ component Counter({ count }: { count: Tracked<number> }) {
80
81
  effect(() => {
81
82
  logs.push(@count);
82
83
  });
@@ -120,7 +121,7 @@ describe('composite > props', () => {
120
121
  </style>
121
122
  }
122
123
 
123
- component Toggle(props) {
124
+ component Toggle(props: { pressed: Tracked<boolean> }) {
124
125
  const [pressed, rest] = trackSplit(props, ['pressed']);
125
126
  const onClick = () => (@pressed = !@pressed);
126
127
  <Button {...@rest} class={@pressed ? 'on' : 'off'} {onClick}>{'button 1'}</Button>
@@ -146,4 +147,48 @@ describe('composite > props', () => {
146
147
  expect(button1.className).toContain('off');
147
148
  expect(button2.className).toContain('off');
148
149
  });
150
+
151
+ it('correctly renders destructured props', () => {
152
+ interface ProductInfo {
153
+ id: string;
154
+ name: string;
155
+ organizationName: string;
156
+ description: string;
157
+ imageUrl: string;
158
+ price: number;
159
+ }
160
+
161
+ component Product({ id, name, organizationName, description, imageUrl, price }: ProductInfo) {
162
+ <article class="no-padding">
163
+ <img class="responsive small" src={imageUrl} alt={name} />
164
+ <span>{id}</span>
165
+ <h5>{name}</h5>
166
+ <h3>{organizationName}</h3>
167
+ <p>{description}</p>
168
+ <price class="price">{price}</price>
169
+ </article>
170
+ }
171
+
172
+ component App() {
173
+ <Product
174
+ id="1"
175
+ name="Product 1"
176
+ organizationName="Org 1"
177
+ description="Description 1"
178
+ imageUrl="https://picsum.photos/300/200"
179
+ price={15}
180
+ />
181
+ }
182
+
183
+ render(App);
184
+
185
+ expect(container.querySelector('img').getAttribute('src')).toBe(
186
+ 'https://picsum.photos/300/200',
187
+ );
188
+ expect(container.querySelector('span').textContent).toBe('1');
189
+ expect(container.querySelector('h5').textContent).toBe('Product 1');
190
+ expect(container.querySelector('h3').textContent).toBe('Org 1');
191
+ expect(container.querySelector('p').textContent).toBe('Description 1');
192
+ expect(container.querySelector('price').textContent).toBe('15');
193
+ });
149
194
  });
@@ -15,6 +15,50 @@ describe('dynamic DOM elements', () => {
15
15
  expect(element.textContent).toBe('Hello World');
16
16
  });
17
17
 
18
+ // The ts errors below are due to limitations in our current tsx generation for dynamic elements.
19
+ // They can be ignored for now. But we'll fix them via jsx() vs <jsx>
20
+ it('renders static dynamic element from a plain object with a tracked property', () => {
21
+ component App() {
22
+ let obj = { tag: track('div') };
23
+
24
+ <obj.@tag>{'Hello World'}</obj.@tag>
25
+ }
26
+ render(App);
27
+
28
+ const element = container.querySelector('div');
29
+ expect(element).toBeTruthy();
30
+ expect(element.textContent).toBe('Hello World');
31
+ });
32
+
33
+ it('renders static dynamic element from a tracked object with a tracked property', () => {
34
+ component App() {
35
+ let obj = track({ tag: track('div') });
36
+
37
+ <@obj.@tag>{'Hello World'}</@obj.@tag>
38
+ }
39
+ render(App);
40
+
41
+ const element = container.querySelector('div');
42
+ expect(element).toBeTruthy();
43
+ expect(element.textContent).toBe('Hello World');
44
+ });
45
+
46
+ it(
47
+ 'renders static dynamic element from a tracked object with a computed tracked property',
48
+ () => {
49
+ component App() {
50
+ let obj = track({ tag: track('div') });
51
+
52
+ <@obj.@['tag']>{'Hello World'}</@obj.@['tag']>
53
+ }
54
+ render(App);
55
+
56
+ const element = container.querySelector('div');
57
+ expect(element).toBeTruthy();
58
+ expect(element.textContent).toBe('Hello World');
59
+ },
60
+ );
61
+
18
62
  it('renders reactive dynamic element', () => {
19
63
  component App() {
20
64
  let tag = track('div');
@@ -316,4 +316,44 @@ describe('switch statements', () => {
316
316
  expect(container.querySelector('div').querySelectorAll('div')[1].id).toBe('b');
317
317
  },
318
318
  );
319
+
320
+ it('renders bare text nodes in switch fall-through cases without element wrappers', () => {
321
+ component App() {
322
+ let value = 'a';
323
+
324
+ <div>
325
+ <span>{'before'}</span>
326
+ switch (value) {
327
+ case 'a':
328
+ {'Case A'}
329
+ case 'b':
330
+ {'Case B'}
331
+ }
332
+ </div>
333
+ }
334
+
335
+ render(App);
336
+ expect(container.querySelector('div').textContent).toBe('beforeCase ACase B');
337
+ });
338
+
339
+ it('renders bare text nodes in switch cases without element wrappers', () => {
340
+ component App() {
341
+ let value = 'a';
342
+
343
+ <div>
344
+ <span>{'before'}</span>
345
+ switch (value) {
346
+ case 'a':
347
+ {'Case A'}
348
+ break;
349
+ case 'b':
350
+ {'Case B'}
351
+ break;
352
+ }
353
+ </div>
354
+ }
355
+
356
+ render(App);
357
+ expect(container.querySelector('div').textContent).toBe('beforeCase A');
358
+ });
319
359
  });
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["../../src/**/*", "../**/*"],
4
+ "exclude": [
5
+ "../server",
6
+ "../server.d.ts",
7
+ "../setup-server.js",
8
+ "../hydration",
9
+ "../setup-hydration.js"
10
+ ]
11
+ }
package/tests/client.d.ts CHANGED
@@ -1,25 +1,8 @@
1
- declare var container: HTMLDivElement;
2
- declare var error: string | undefined;
3
- declare function render(component: () => void): void;
1
+ import type { Component } from '#public';
4
2
 
5
- // Helper type for test attributes that allows custom data-* and other attributes
6
- type TestAttributes = Record<string, any>;
3
+ declare global {
4
+ function render(component: Component): void;
7
5
 
8
- type TagNameMap = HTMLElementTagNameMap & SVGElementTagNameMap;
9
-
10
- interface HTMLElement {
11
- // We don't care about checking if it returned an element or null in tests
12
- // because if it returned null, those tests will fail anyway. This
13
- // typing drastically simplifies testing: you don't have to check if the
14
- // query returned null or an actual element, and you don't have to do
15
- // optional chaining everywhere (elem?.textContent)
16
- querySelector<K extends keyof TagNameMap>(selectors: K): TagNameMap[K];
17
- querySelector(selectors: string): HTMLElement;
18
- querySelectorAll<K extends keyof TagNameMap>(
19
- selectors: K,
20
- ): NodeListOf<TagNameMap[K]>;
21
- querySelectorAll(selectors: string): NodeListOf<Element>;
22
-
23
- // Allow dynamic properties for delegated event handlers
24
- [key: string]: any;
6
+ var container: HTMLDivElement;
7
+ var error: string | undefined;
25
8
  }
@@ -0,0 +1,24 @@
1
+ // export allows this file to be treated as a module
2
+ export {};
3
+
4
+ declare global {
5
+ // Helper type for test attributes that allows custom data-* and other attributes
6
+ type TestAttributes = Record<string, any>;
7
+
8
+ type TagNameMap = HTMLElementTagNameMap & SVGElementTagNameMap;
9
+
10
+ interface ParentNode {
11
+ // We don't care about checking if it returned an element or null in tests
12
+ // because if it returned null, those tests will fail anyway. This
13
+ // typing drastically simplifies testing: you don't have to check if the
14
+ // query returned null or an actual element, and you don't have to do
15
+ // optional chaining everywhere (elem?.textContent)
16
+ querySelector<K extends keyof TagNameMap>(selectors: K): TagNameMap[K];
17
+ querySelector(selectors: string): HTMLElement;
18
+ querySelectorAll<K extends keyof TagNameMap>(selectors: K): NodeListOf<TagNameMap[K]>;
19
+ querySelectorAll(selectors: string): NodeListOf<Element>;
20
+
21
+ // Allow dynamic properties for delegated event handlers
22
+ [key: string]: any;
23
+ }
24
+ }