ripple 0.2.121 → 0.2.124

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.121",
6
+ "version": "0.2.124",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -582,6 +582,14 @@ const visitors = {
582
582
  }
583
583
  binding.metadata.is_dynamic_component = true;
584
584
  }
585
+
586
+ if (!is_dom_element && state.elements) {
587
+ state.elements.push(node);
588
+ // Mark dynamic elements as scoped by default since we can't match CSS at compile time
589
+ if (state.component?.css) {
590
+ node.metadata.scoped = true;
591
+ }
592
+ }
585
593
  }
586
594
 
587
595
  if (is_dom_element) {
@@ -840,6 +840,14 @@ const visitors = {
840
840
  let property =
841
841
  attr.value === null ? b.literal(true) : visit(attr.value, { ...state, metadata });
842
842
 
843
+ if (attr.name.name === 'class' && node.metadata.scoped && state.component.css) {
844
+ if (property.type === 'Literal') {
845
+ property = b.literal(`${state.component.css.hash} ${property.value}`);
846
+ } else {
847
+ property = b.array([property, b.literal(state.component.css.hash)]);
848
+ }
849
+ }
850
+
843
851
  if (metadata.tracking || attr.name.tracked) {
844
852
  if (attr.name.name === 'children') {
845
853
  children_prop = b.thunk(property);
@@ -872,6 +880,17 @@ const visitors = {
872
880
  }
873
881
  }
874
882
 
883
+ if (node.metadata.scoped && state.component.css) {
884
+ const hasClassAttr = node.attributes.some(attr =>
885
+ attr.type === 'Attribute' && attr.name.type === 'Identifier' && attr.name.name === 'class'
886
+ );
887
+ if (!hasClassAttr) {
888
+ const name = is_spreading ? '#class' : 'class';
889
+ const value = state.component.css.hash;
890
+ props.push(b.prop('init', b.key(name), b.literal(value)));
891
+ }
892
+ }
893
+
875
894
  const children_filtered = [];
876
895
 
877
896
  for (const child of node.children) {
@@ -1,8 +1,7 @@
1
1
  /** @import { Block, Component } from '#client' */
2
2
 
3
- import { branch, destroy_block, render } from './blocks.js';
3
+ import { branch, destroy_block, render, render_spread } from './blocks.js';
4
4
  import { COMPOSITE_BLOCK } from './constants.js';
5
- import { apply_element_spread } from './render';
6
5
  import { active_block } from './runtime.js';
7
6
 
8
7
  /**
@@ -48,8 +47,7 @@ export function composite(get_component, node, props) {
48
47
  };
49
48
  }
50
49
 
51
- const spread_fn = apply_element_spread(element, () => props || {});
52
- spread_fn();
50
+ render_spread(element, () => props || {});
53
51
 
54
52
  if (typeof props?.children === 'function') {
55
53
  var child_anchor = document.createComment('');
@@ -1,4 +1,4 @@
1
- import { effect } from './blocks';
1
+ import { effect } from './blocks.js';
2
2
  /**
3
3
  * @param {Text | Comment} node
4
4
  * @param {string} content
@@ -1,4 +1,5 @@
1
1
  /** @import { Component, Derived } from '#server' */
2
+ /** @import { render } from 'ripple/server'*/
2
3
  import { DERIVED, UNINITIALIZED } from '../client/constants.js';
3
4
  import { is_tracked_object } from '../client/utils.js';
4
5
  import { escape } from '../../../utils/escaping.js';
@@ -56,10 +57,7 @@ class Output {
56
57
  }
57
58
  }
58
59
 
59
- /**
60
- * @param {((output: Output, props: Record<string, any>) => void | Promise<void>) & { async?: boolean }} component
61
- * @returns {Promise<{head: string, body: string, css: Set<string>}>}
62
- */
60
+ /** @type {render} */
63
61
  export async function render(component) {
64
62
  const output = new Output(null);
65
63
 
@@ -0,0 +1,291 @@
1
+ import { track, flushSync, get, set, effect, untrack } from 'ripple';
2
+
3
+ describe('basic client > get/set functions', () => {
4
+ it('gets tracked value', () => {
5
+ component Test() {
6
+ let count = track(0);
7
+
8
+ <div>{get(count)}</div>
9
+ }
10
+
11
+ render(Test);
12
+
13
+ const div = container.querySelector('div');
14
+ expect(div.textContent).toBe('0');
15
+ });
16
+
17
+ it('gets tracked value after mutation', () => {
18
+ component Test() {
19
+ let count = track(0);
20
+
21
+ <p>{get(count)}</p>
22
+ <button onClick={() => @count++}>{'increment'}</button>
23
+ }
24
+
25
+ render(Test);
26
+
27
+ const p = container.querySelector('p');
28
+ expect(p.textContent).toBe('0');
29
+
30
+ const button = container.querySelector('button');
31
+ button.click();
32
+ flushSync();
33
+
34
+ expect(p.textContent).toBe('1');
35
+ });
36
+
37
+ it('gets tracked value after multiple mutations', () => {
38
+ component Test() {
39
+ let count = track(0);
40
+
41
+ <p>{get(count)}</p>
42
+ <button onClick={() => {
43
+ @count++;
44
+ @count++;
45
+ @count++;
46
+ }}>{'increment'}</button>
47
+ }
48
+
49
+ render(Test);
50
+
51
+ const p = container.querySelector('p');
52
+ expect(p.textContent).toBe('0');
53
+
54
+ const button = container.querySelector('button');
55
+ button.click();
56
+ flushSync();
57
+
58
+ expect(p.textContent).toBe('3');
59
+ });
60
+
61
+ it('sets tracked value', () => {
62
+ component Test() {
63
+ let count = track(0);
64
+
65
+ <p>{get(count)}</p>
66
+ <button onClick={() => set(count, 10)}>{'set to 10'}</button>
67
+ }
68
+
69
+ render(Test);
70
+
71
+ const p = container.querySelector('p');
72
+ expect(p.textContent).toBe('0');
73
+
74
+ const button = container.querySelector('button');
75
+ button.click();
76
+ flushSync();
77
+
78
+ expect(p.textContent).toBe('10');
79
+ });
80
+
81
+ it('sets tracked value multiple times', () => {
82
+ component Test() {
83
+ let count = track(0);
84
+
85
+ <p>{get(count)}</p>
86
+ <button onClick={() => {
87
+ set(count, 5);
88
+ set(count, 15);
89
+ set(count, 25);
90
+ }}>{'set multiple times'}</button>
91
+ }
92
+
93
+ render(Test);
94
+
95
+ const p = container.querySelector('p');
96
+ expect(p.textContent).toBe('0');
97
+
98
+ const button = container.querySelector('button');
99
+ button.click();
100
+ flushSync();
101
+
102
+ expect(p.textContent).toBe('25');
103
+ });
104
+
105
+ it('sets tracked value based on previous value', () => {
106
+ component Test() {
107
+ let count = track(0);
108
+
109
+ <p>{get(count)}</p>
110
+ <button onClick={() => set(count, get(count) + 10)}>{'add 10'}</button>
111
+ }
112
+
113
+ render(Test);
114
+
115
+ const p = container.querySelector('p');
116
+ expect(p.textContent).toBe('0');
117
+
118
+ const button = container.querySelector('button');
119
+
120
+ button.click();
121
+ flushSync();
122
+
123
+ expect(p.textContent).toBe('10');
124
+
125
+ button.click();
126
+ flushSync();
127
+
128
+ expect(p.textContent).toBe('20');
129
+ });
130
+
131
+ it('sets tracked value multiple times based on previous value', () => {
132
+ component Test() {
133
+ let count = track(0);
134
+
135
+ <p>{get(count)}</p>
136
+ <button onClick={() => {
137
+ set(count, get(count) + 5);
138
+ set(count, get(count) + 15);
139
+ set(count, get(count) + 25);
140
+ }}>{'add multiple times'}</button>
141
+ }
142
+
143
+ render(Test);
144
+
145
+ const p = container.querySelector('p');
146
+ expect(p.textContent).toBe('0');
147
+
148
+ const button = container.querySelector('button');
149
+
150
+ button.click();
151
+ flushSync();
152
+
153
+ expect(p.textContent).toBe('45');
154
+
155
+ button.click();
156
+ flushSync();
157
+
158
+ expect(p.textContent).toBe('90');
159
+ });
160
+
161
+ function store() {
162
+ return track(0);
163
+ }
164
+
165
+ it('gets value declared outside Ripple component', () => {
166
+ component Test() {
167
+ let count = store();
168
+ <p>{get(count)}</p>
169
+ }
170
+
171
+ render(Test);
172
+
173
+ const p = container.querySelector('p');
174
+ expect(p.textContent).toBe('0');
175
+ });
176
+
177
+ it('sets value declared outside Ripple component', () => {
178
+ component Test() {
179
+ let count = store();
180
+
181
+ <p>{get(count)}</p>
182
+ <button onClick={() => set(count, 50)}>{'set to 50'}</button>
183
+ }
184
+
185
+ render(Test);
186
+
187
+ const p = container.querySelector('p');
188
+ expect(p.textContent).toBe('0');
189
+
190
+ const button = container.querySelector('button');
191
+ button.click();
192
+ flushSync();
193
+
194
+ expect(p.textContent).toBe('50');
195
+ });
196
+
197
+ it('works with effects', () => {
198
+ component Test() {
199
+ let count = track(0);
200
+ let double = track(0);
201
+
202
+ effect(() => {
203
+ set(double, get(count) * 2);
204
+ });
205
+
206
+ <p>{get(double)}</p>
207
+ <button onClick={() => set(count, get(count) + 1)}>{'increment'}</button>
208
+ }
209
+
210
+ render(Test);
211
+
212
+ const p = container.querySelector('p');
213
+ expect(p.textContent).toBe('0');
214
+
215
+ const button = container.querySelector('button');
216
+ button.click();
217
+ flushSync();
218
+
219
+ expect(p.textContent).toBe('2');
220
+
221
+ button.click();
222
+ flushSync();
223
+
224
+ expect(p.textContent).toBe('4');
225
+ });
226
+
227
+ it('works with effects and untrack', () => {
228
+ component Test() {
229
+ let count = track(0);
230
+ let double = track(0);
231
+
232
+ effect(() => {
233
+ untrack(() => {
234
+ set(double, get(count) * 2);
235
+ });
236
+ });
237
+
238
+ <p>{get(double)}</p>
239
+ <button onClick={() => set(count, get(count) + 1)}>{'increment'}</button>
240
+ }
241
+
242
+ render(Test);
243
+
244
+ const p = container.querySelector('p');
245
+ expect(p.textContent).toBe('0');
246
+
247
+ const button = container.querySelector('button');
248
+
249
+ button.click();
250
+ flushSync();
251
+
252
+ expect(p.textContent).toBe('2');
253
+
254
+ button.click();
255
+ flushSync();
256
+
257
+ expect(p.textContent).toBe('2');
258
+ });
259
+
260
+ it("get isn't reactive when declared outside Ripple context", () => {
261
+ let count = store();
262
+
263
+ component Test() {
264
+ <p>{get(count)}</p>
265
+ <button onClick={() => { set(count, get(count) + 1) }}>{'increment'}</button>
266
+ }
267
+
268
+ expect(get(count)).toBe(0);
269
+
270
+ render(Test);
271
+
272
+ const p = container.querySelector('p');
273
+ expect(p.textContent).toBe('0');
274
+ expect(get(count)).toBe(0);
275
+
276
+ const button = container.querySelector('button');
277
+ button.click();
278
+ flushSync();
279
+
280
+ expect(p.textContent).toBe('0');
281
+ expect(get(count)).toBe(1);
282
+ });
283
+
284
+ it('throws on trying to set a value outside Ripple component', () => {
285
+ let count = store();
286
+
287
+ expect(get(count)).toBe(0);
288
+ expect(() => set(count, 1)).toThrow();
289
+ expect(get(count)).toBe(0);
290
+ });
291
+ });
@@ -1,6 +1,7 @@
1
- import { flushSync, track, createRefKey } from 'ripple';
1
+ import { flushSync, track, createRefKey, trackSplit } from 'ripple';
2
2
 
3
3
  describe('dynamic DOM elements', () => {
4
+
4
5
  it('renders static dynamic element', () => {
5
6
  component App() {
6
7
  let tag = track('div');
@@ -24,14 +25,17 @@ describe('dynamic DOM elements', () => {
24
25
  <@tag id="dynamic">{'Hello World'}</@tag>
25
26
  }
26
27
  render(App);
28
+
27
29
  // Initially should be a div
28
30
  let dynamicElement = container.querySelector('#dynamic');
29
31
  expect(dynamicElement.tagName).toBe('DIV');
30
32
  expect(dynamicElement.textContent).toBe('Hello World');
33
+
31
34
  // Click button to change tag
32
35
  const button = container.querySelector('button');
33
36
  button.click();
34
37
  flushSync();
38
+
35
39
  // Should now be a span
36
40
  dynamicElement = container.querySelector('#dynamic');
37
41
  expect(dynamicElement.tagName).toBe('SPAN');
@@ -46,7 +50,7 @@ describe('dynamic DOM elements', () => {
46
50
  }
47
51
  render(App);
48
52
 
49
- const element = container.querySelector('input') as HTMLInputElement;
53
+ const element = container.querySelector('input');
50
54
  expect(element).toBeTruthy();
51
55
  expect(element.type).toBe('text');
52
56
  expect(element.value).toBe('test');
@@ -109,8 +113,8 @@ describe('dynamic DOM elements', () => {
109
113
  component App() {
110
114
  let tag = track('span');
111
115
 
112
- <@tag style={{
113
- color: 'red',
116
+ <@tag style={{
117
+ color: 'red',
114
118
  fontSize: '16px',
115
119
  fontWeight: 'bold'
116
120
  }}>
@@ -150,17 +154,18 @@ describe('dynamic DOM elements', () => {
150
154
  });
151
155
 
152
156
  it('handles dynamic element with ref', () => {
153
- let capturedElement: HTMLArticleElement | undefined;
157
+ let capturedElement = null;
154
158
 
155
159
  component App() {
156
160
  let tag = track('article');
157
161
 
158
- <@tag {ref (node: HTMLArticleElement) => { capturedElement = node; }} id="ref-test">
162
+ <@tag {ref (node) => { capturedElement = node; }} id="ref-test">
159
163
  {'Element with ref'}
160
164
  </@tag>
161
165
  }
162
166
  render(App);
163
167
  flushSync();
168
+
164
169
  expect(capturedElement).toBeTruthy();
165
170
  expect(capturedElement.tagName).toBe('ARTICLE');
166
171
  expect(capturedElement.id).toBe('ref-test');
@@ -171,7 +176,7 @@ describe('dynamic DOM elements', () => {
171
176
  component App() {
172
177
  let tag = track('header');
173
178
 
174
- function elementRef(node: HTMLHeaderElement) {
179
+ function elementRef(node) {
175
180
  // Set an attribute on the element to prove ref was called
176
181
  node.setAttribute('data-spread-ref-called', 'true');
177
182
  node.setAttribute('data-spread-ref-tag', node.tagName.toLowerCase());
@@ -196,4 +201,201 @@ describe('dynamic DOM elements', () => {
196
201
  expect(element.id).toBe('spread-ref-test');
197
202
  expect(element.className).toBe('ref-element');
198
203
  });
199
- });
204
+
205
+ it('has reactive attributes on dynamic elements', () => {
206
+ component App() {
207
+ let tag = track('div');
208
+ let count = track(0);
209
+
210
+ <button onClick={() => { @count++; }}>{'Increment'}</button>
211
+ <@tag
212
+ id={@count % 2 ? 'even' : 'odd'}
213
+ class={@count % 2 ? 'even-class' : 'odd-class'}
214
+ data-count={@count}
215
+ >
216
+ {'Count: '}{@count}
217
+ </@tag>
218
+ }
219
+
220
+ render(App);
221
+
222
+ const button = container.querySelector('button');
223
+ const element = container.querySelector('div');
224
+
225
+ // Initial state
226
+ expect(element.id).toBe('odd');
227
+ expect(element.className).toBe('odd-class');
228
+ expect(element.getAttribute('data-count')).toBe('0');
229
+ expect(element.textContent).toBe('Count: 0');
230
+
231
+ // Click to increment
232
+ button.click();
233
+ flushSync();
234
+
235
+ // Attributes should be reactive and update
236
+ expect(element.id).toBe('even');
237
+ expect(element.className).toBe('even-class');
238
+ expect(element.getAttribute('data-count')).toBe('1');
239
+ expect(element.textContent).toBe('Count: 1');
240
+
241
+ // Click again
242
+ button.click();
243
+ flushSync();
244
+
245
+ // Should toggle back
246
+ expect(element.id).toBe('odd');
247
+ expect(element.className).toBe('odd-class');
248
+ expect(element.getAttribute('data-count')).toBe('2');
249
+ expect(element.textContent).toBe('Count: 2');
250
+ });
251
+
252
+ it('applies scoped CSS to dynamic elements', () => {
253
+ component App() {
254
+ let tag = track('div');
255
+
256
+ <@tag class="test-class">{'Dynamic element'}</@tag>
257
+
258
+ <style>
259
+ .test-class {
260
+ color: red;
261
+ }
262
+ </style>
263
+ }
264
+
265
+ render(App);
266
+
267
+ const element = container.querySelector('div');
268
+ expect(element).toBeTruthy();
269
+ console.log(element);
270
+ expect(element.classList.contains('test-class')).toBe(true);
271
+
272
+ // Check if scoped CSS class is present - THIS MIGHT FAIL if CSS pruning issue exists
273
+ const classes = Array.from(element.classList);
274
+ const hasScopedClass = classes.some(cls => cls.startsWith('ripple-'));
275
+ expect(hasScopedClass).toBe(true);
276
+ });
277
+
278
+ it('applies scoped CSS to dynamic elements with reactive classes', () => {
279
+ component App() {
280
+ let tag = track('button');
281
+ let count = track(0);
282
+
283
+ <@tag
284
+ class={@count % 2 ? 'even' : 'odd'}
285
+ id={@count % 2 ? 'even' : 'odd'}
286
+ onClick={() => { @count++; }}
287
+ >
288
+ {'Count: '}{@count}
289
+ </@tag>
290
+
291
+ <style>
292
+ .even {
293
+ background-color: green;
294
+ color: white;
295
+ }
296
+ .odd {
297
+ background-color: red;
298
+ color: white;
299
+ }
300
+ </style>
301
+ }
302
+
303
+ render(App);
304
+
305
+ const button = container.querySelector('button');
306
+ expect(button).toBeTruthy();
307
+
308
+ // Initial state: should be odd (count=0, 0%2=false)
309
+ expect(button.classList.contains('odd')).toBe(true);
310
+ expect(button.classList.contains('even')).toBe(false);
311
+ expect(button.id).toBe('odd');
312
+ expect(button.textContent).toBe('Count: 0');
313
+
314
+ // Check if scoped CSS hash is applied to dynamic element
315
+ const classes = Array.from(button.classList);
316
+ const hasScopedClass = classes.some(cls => cls.startsWith('ripple-'));
317
+ expect(hasScopedClass).toBe(true);
318
+
319
+ // Click to increment
320
+ button.click();
321
+ flushSync();
322
+
323
+ // Should now be even (count=1, 1%2=true)
324
+ expect(button.classList.contains('even')).toBe(true);
325
+ expect(button.classList.contains('odd')).toBe(false);
326
+ expect(button.id).toBe('even');
327
+ expect(button.textContent).toBe('Count: 1');
328
+
329
+ // Scoped CSS class should still be present
330
+ const newClasses = Array.from(button.classList);
331
+ const stillHasScopedClass = newClasses.some(cls => cls.startsWith('ripple-'));
332
+ expect(stillHasScopedClass).toBe(true);
333
+
334
+ // Click again
335
+ button.click();
336
+ flushSync();
337
+
338
+ // Should toggle back to odd (count=2, 2%2=false)
339
+ expect(button.classList.contains('odd')).toBe(true);
340
+ expect(button.classList.contains('even')).toBe(false);
341
+ expect(button.id).toBe('odd');
342
+ expect(button.textContent).toBe('Count: 2');
343
+ });
344
+
345
+ it('handles spread attributes with class and CSS scoping ', () => {
346
+ component DyanamicButton(props) {
347
+ const tag = track('button');
348
+ const [children, rest] = trackSplit(props, ['children']);
349
+ <@tag {...@rest}>{@rest.class}</@tag>
350
+
351
+ <style>
352
+ .even {
353
+ background-color: green;
354
+ }
355
+ .odd {
356
+ background-color: red;
357
+ }
358
+ </style>
359
+ }
360
+
361
+ component App() {
362
+ const count = track(0);
363
+ <DyanamicButton
364
+ class={@count % 2 ? 'even' : 'odd'}
365
+ id={@count % 2 ? 'even' : 'odd'}
366
+ onClick={() => { @count++; }}
367
+ />
368
+ }
369
+
370
+ render(App);
371
+
372
+ const button = container.querySelector('button');
373
+ expect(button).toBeTruthy();
374
+
375
+
376
+ // Initial state: should be odd (count=0, 0%2=false)
377
+ expect(button.classList.contains('odd')).toBe(true);
378
+ expect(button.classList.contains('even')).toBe(false);
379
+ expect(button.id).toBe('odd');
380
+
381
+ // Check if scoped CSS hash is applied (this is the critical test)
382
+ const classes = Array.from(button.classList);
383
+ const hasScopedClass = classes.some(cls => cls.startsWith('ripple-'));
384
+ expect(hasScopedClass).toBe(true);
385
+
386
+ // Click to increment
387
+ button.click();
388
+ flushSync();
389
+
390
+ // Should now be even (count=1, 1%2=true)
391
+ expect(button.classList.contains('even')).toBe(true);
392
+ expect(button.classList.contains('odd')).toBe(false);
393
+ expect(button.id).toBe('even');
394
+
395
+ // Both classes should still be present
396
+ const newClasses = Array.from(button.classList);
397
+ const stillHasScopedClass = newClasses.some(cls => cls.startsWith('ripple-'));
398
+ expect(stillHasScopedClass).toBe(true);
399
+ expect(newClasses.includes('even')).toBe(true);
400
+ });
401
+ });
package/types/server.d.ts CHANGED
@@ -1,4 +1,26 @@
1
+ import type { Props } from '#public'
1
2
 
2
- export declare async function render(
3
- component: () => void,
4
- ): { head: string; body: string };
3
+ export interface SSRRenderOutput {
4
+ head: string;
5
+ body: string;
6
+ css: Set<string>;
7
+ push(chunk: string): void;
8
+ register_css(hash: string): void;
9
+ }
10
+
11
+ export interface SSRComponent {
12
+ (output: SSRRenderOutput, props?: Props): void | Promise<void>;
13
+ async?: boolean;
14
+ }
15
+
16
+ export interface SSRRenderResult {
17
+ head: string;
18
+ body: string;
19
+ css: Set<string>;
20
+ }
21
+
22
+ export type SSRRender = (component: SSRComponent) => Promise<SSRRenderResult>;
23
+
24
+ export declare function render(
25
+ component: SSRComponent,
26
+ ): Promise<SSRRenderResult>;