ripple 0.3.125 → 0.3.127

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.
@@ -671,7 +671,9 @@ describe('dynamic DOM elements', () => {
671
671
  expect(innerScopes).toHaveLength(1);
672
672
  expect(innerScopes[0]).not.toBe(outerScope);
673
673
 
674
- expect(innerInnerScopes).toHaveLength(0);
674
+ // Every element of the child's scope carries the child's hash, and none
675
+ // of the parent's.
676
+ expect(innerInnerScopes).toEqual(innerScopes);
675
677
  });
676
678
 
677
679
  it('doesn\'t add scoping class to dynamically rendered component', () => {
@@ -708,7 +710,8 @@ describe('dynamic DOM elements', () => {
708
710
  const innerScopes = Array.from(p.classList).filter((c) => c.startsWith('tsrx-'));
709
711
 
710
712
  expect(outerScopes).toHaveLength(1);
711
- expect(innerScopes).toHaveLength(0);
713
+ // The child's own scope reaches its <p>; the parent's never does.
714
+ expect(innerScopes).toEqual(outerScopes);
712
715
  });
713
716
 
714
717
  it('handles ref on dynamic element passed through component with reactive props', () => {
@@ -0,0 +1,20 @@
1
+ // A theme module shared by the scoped-style tests: `base` is applied by
2
+ // `theme`, so `theme.$class` carries both hashes and `theme` keeps every
3
+ // selector (it is exported).
4
+ export const base = <style>
5
+ article {
6
+ font-family: system-ui;
7
+ }
8
+ .muted {
9
+ color: gray;
10
+ }
11
+ </style>;
12
+
13
+ export const theme = <style apply={base}>
14
+ article {
15
+ color: green;
16
+ }
17
+ .dark {
18
+ color: purple;
19
+ }
20
+ </style>;
@@ -398,7 +398,9 @@ describe('server dynamic DOM elements', () => {
398
398
  expect(innerScopes).toHaveLength(1);
399
399
  expect(innerScopes[0]).not.toBe(outerScope);
400
400
 
401
- expect(innerInnerScopes).toHaveLength(0);
401
+ // Every element of the child's scope carries the child's hash, and none
402
+ // of the parent's.
403
+ expect(innerInnerScopes).toEqual(innerScopes);
402
404
  });
403
405
 
404
406
  it('doesn\'t add scoping class to dynamically rendered component', async () => {
@@ -437,6 +439,7 @@ describe('server dynamic DOM elements', () => {
437
439
  const innerScopes = Array.from(p.classList).filter((c) => c.startsWith('tsrx-'));
438
440
 
439
441
  expect(outerScopes).toHaveLength(1);
440
- expect(innerScopes).toHaveLength(0);
442
+ // The child's own scope reaches its <p>; the parent's never does.
443
+ expect(innerScopes).toEqual(outerScopes);
441
444
  });
442
445
  });
@@ -0,0 +1,299 @@
1
+ import { getCss } from 'ripple/server';
2
+ import { base as importedBase, theme as importedTheme } from '../fixtures/scoped-styles/theme.tsrx';
3
+
4
+ // RFC tsrx-org/RFCs#1 on the server: the class table matches the client, and
5
+ // a request collects the sheets of the scopes it rendered — an applied theme
6
+ // before the scope that applies it, whichever module the theme lives in.
7
+
8
+ function hashes(element: Element | null): string[] {
9
+ return Array.from(element?.classList ?? []).filter((cls) => cls.startsWith('tsrx-'));
10
+ }
11
+
12
+ function tokens(value: string): string[] {
13
+ return value.split(' ').filter(Boolean);
14
+ }
15
+
16
+ const one = <style>
17
+ div {
18
+ color: red;
19
+ }
20
+ </style>;
21
+
22
+ describe('sibling-scoped style blocks (server)', () => {
23
+ function Status({ ready }: { ready: boolean }) @{
24
+ <>
25
+ <style>
26
+ .status {
27
+ padding: 0.5rem;
28
+ }
29
+ </style>
30
+ <section id="status" class="status">
31
+ <style>
32
+ .title {
33
+ font-weight: 700;
34
+ }
35
+ .status {
36
+ color: rgb(9, 9, 9);
37
+ }
38
+ </style>
39
+ <h2 id="title" class="title">{'Status'}</h2>
40
+ @if (ready) {
41
+ <>
42
+ <style>
43
+ .ok {
44
+ color: green;
45
+ }
46
+ </style>
47
+ <p id="ok" class="ok">{'Ready'}</p>
48
+ </>
49
+ } @else {
50
+ <>
51
+ <style>
52
+ .wait {
53
+ color: gray;
54
+ }
55
+ </style>
56
+ <p id="wait" class="wait">{'Waiting'}</p>
57
+ </>
58
+ }
59
+ </section>
60
+ </>
61
+ }
62
+
63
+ it('renders the class table and collects every scope sheet in source order', async () => {
64
+ function Ready() @{
65
+ <Status ready={true} />
66
+ }
67
+
68
+ const { body, css } = await render(Ready);
69
+ const { document } = parseHtml(body);
70
+
71
+ const [A] = hashes(document.querySelector('#status'));
72
+ expect(hashes(document.querySelector('#status'))).toEqual([A]);
73
+ const B = hashes(document.querySelector('#title'))[1];
74
+ expect(hashes(document.querySelector('#title'))).toEqual([A, B]);
75
+ const C = hashes(document.querySelector('#ok'))[2];
76
+ expect(hashes(document.querySelector('#ok'))).toEqual([A, B, C]);
77
+ expect(document.querySelector('#wait')).toBeNull();
78
+
79
+ // Every scope's CSS ships, the branch never rendered included, and the
80
+ // sheets follow source order.
81
+ const collected = [...css];
82
+ expect(collected).toHaveLength(4);
83
+ expect(collected.slice(0, 3)).toEqual([A, B, C]);
84
+ const text = getCss(css);
85
+ expect(text).toContain(`.status.${A}`);
86
+ expect(text).toContain(`.title.${B}`);
87
+ expect(text).toContain('/* (unused) .status {');
88
+ expect(text).toContain(`.ok.${C}`);
89
+ expect(text).toContain(`.wait.${collected[3]}`);
90
+ expect(text.indexOf(`.status.${A}`)).toBeLessThan(text.indexOf(`.title.${B}`));
91
+ expect(text.indexOf(`.title.${B}`)).toBeLessThan(text.indexOf(`.ok.${C}`));
92
+ });
93
+
94
+ it('stamps the other branch with its own hash', async () => {
95
+ function Waiting() @{
96
+ <Status ready={false} />
97
+ }
98
+
99
+ const { body } = await render(Waiting);
100
+ const { document } = parseHtml(body);
101
+
102
+ const wait = hashes(document.querySelector('#wait'));
103
+ expect(wait).toHaveLength(3);
104
+ expect(wait.slice(0, 2)).toEqual(hashes(document.querySelector('#title')));
105
+ expect(document.querySelector('#ok')).toBeNull();
106
+ });
107
+
108
+ it('registers the blocks of one children list as one sheet', async () => {
109
+ function Two() @{
110
+ <>
111
+ <style>
112
+ .host {
113
+ margin: 0;
114
+ }
115
+ </style>
116
+ <div id="host" class="host">
117
+ <style>
118
+ .a {
119
+ color: red;
120
+ }
121
+ </style>
122
+ <i class="a">{'a'}</i>
123
+ <style>
124
+ .b {
125
+ color: blue;
126
+ }
127
+ </style>
128
+ <b class="b">{'b'}</b>
129
+ </div>
130
+ </>
131
+ }
132
+
133
+ const { body, css } = await render(Two);
134
+ const { document } = parseHtml(body);
135
+
136
+ const [H] = hashes(document.querySelector('#host'));
137
+ const [, C] = hashes(document.querySelector('i'));
138
+ expect(hashes(document.querySelector('b'))).toEqual([H, C]);
139
+ expect([...css]).toEqual([H, C]);
140
+ const text = getCss(css);
141
+ expect(text).toContain(`.a.${C}`);
142
+ expect(text).toContain(`.b.${C}`);
143
+ expect(text.match(new RegExp(`\\.a\\.${C}`, 'g'))).toHaveLength(1);
144
+ });
145
+
146
+ it('merges the scope classes into a spread', async () => {
147
+ function Button(&{ ...rest }: { id: string; class?: string }) @{
148
+ <>
149
+ <style>
150
+ button {
151
+ padding: 0;
152
+ }
153
+ </style>
154
+ <div>
155
+ <style>
156
+ button {
157
+ margin: 0;
158
+ }
159
+ </style>
160
+ <button {...rest}>{'b'}</button>
161
+ </div>
162
+ </>
163
+ }
164
+
165
+ function App() @{
166
+ <Button id="btn" class="authored" />
167
+ }
168
+
169
+ const { body } = await render(App);
170
+ const { document } = parseHtml(body);
171
+
172
+ const button = document.querySelector('#btn');
173
+ expect(button?.classList.contains('authored')).toBe(true);
174
+ expect(hashes(button)).toHaveLength(2);
175
+ });
176
+ });
177
+
178
+ describe('spreads with an imported theme (server)', () => {
179
+ it('merges the scope classes of a static class into the spread once', async () => {
180
+ function Card(&{ ...rest }: { class?: string }) @{
181
+ <>
182
+ <style apply={importedTheme}>
183
+ .box {
184
+ color: red;
185
+ }
186
+ </style>
187
+ <div id="box" class="box" {...rest}>{'x'}</div>
188
+ </>
189
+ }
190
+
191
+ function App() @{
192
+ <Card class="from-spread" />
193
+ }
194
+
195
+ const { body } = await render(App);
196
+ expect(body.match(/class=/g)).toHaveLength(1);
197
+ const { document } = parseHtml(body);
198
+
199
+ const box = document.querySelector('#box');
200
+ const [local] = hashes(box);
201
+ expect(local).toBeTruthy();
202
+ expect(hashes(box)).toEqual([local, ...tokens(importedTheme.$class)]);
203
+ expect(box?.classList.contains('box')).toBe(true);
204
+ });
205
+ });
206
+
207
+ describe('$class and apply (server)', () => {
208
+ it(
209
+ 'applies a same-module theme and registers its sheet before the scope that applies it',
210
+ async () => {
211
+ function WithBody() @{
212
+ <>
213
+ <style apply={one}>
214
+ .c {
215
+ margin: 0;
216
+ }
217
+ </style>
218
+ <div id="c1" class="c">{'c'}</div>
219
+ </>
220
+ }
221
+
222
+ const { body, css } = await render(WithBody);
223
+ const { document } = parseHtml(body);
224
+
225
+ const div = document.querySelector('#c1');
226
+ const [local, applied] = hashes(div);
227
+ expect(div?.className).toBe(`c ${local} ${one.$class}`);
228
+ expect(applied).toBe(one.$class);
229
+ expect([...css]).toEqual([one.$class, local]);
230
+ const text = getCss(css);
231
+ expect(text.indexOf(`div.${one.$class}`)).toBeLessThan(text.indexOf(`.c.${local}`));
232
+ },
233
+ );
234
+
235
+ it('applies an imported theme through its $class and collects its sheets first', async () => {
236
+ function Card() @{
237
+ <>
238
+ <style apply={importedTheme}>
239
+ h2 {
240
+ margin: 0;
241
+ }
242
+ </style>
243
+ <article id="card">
244
+ <h2 id="h2" class={importedTheme.dark}>{'Green, system-ui'}</h2>
245
+ </article>
246
+ </>
247
+ }
248
+
249
+ const { body, css } = await render(Card);
250
+ const { document } = parseHtml(body);
251
+
252
+ const card = document.querySelector('#card');
253
+ const [local] = hashes(card);
254
+ expect(hashes(card)).toEqual([local, ...tokens(importedTheme.$class)]);
255
+ // The class list is normalized on the server, so the theme hash that
256
+ // `theme.dark` already carries appears once.
257
+ const h2 = Array.from(document.querySelector('#h2')?.classList ?? []);
258
+ expect(h2.slice(0, 2)).toEqual(tokens(importedTheme.dark));
259
+ expect(h2).toContain(local);
260
+ for (const token of tokens(importedTheme.$class)) expect(h2).toContain(token);
261
+ // base (applied by theme), theme, then the applying scope.
262
+ expect([...css]).toEqual([...tokens(importedTheme.$class), local]);
263
+ const text = getCss(css);
264
+ expect(text.indexOf(`article.${importedBase.$class}`)).toBeLessThan(
265
+ text.indexOf(`article.${tokens(importedTheme.$class)[1]}`),
266
+ );
267
+ expect(text.indexOf(`.dark.${tokens(importedTheme.$class)[1]}`)).toBeLessThan(
268
+ text.indexOf(`h2.${local}`),
269
+ );
270
+ });
271
+
272
+ it('registers a theme only when something reads it', async () => {
273
+ function App() @{
274
+ const unused = <style>
275
+ .u {
276
+ color: red;
277
+ }
278
+ </style>;
279
+ const theme = <style>
280
+ div {
281
+ color: blue;
282
+ }
283
+ </style>;
284
+ <>
285
+ <div id="opted" class={theme.$class}>{'opted in'}</div>
286
+ <p id="untouched">{'untouched'}</p>
287
+ </>
288
+ }
289
+
290
+ const { body, css } = await render(App);
291
+ const { document } = parseHtml(body);
292
+
293
+ const theme_hash = document.querySelector('#opted')?.className;
294
+ expect(tokens(theme_hash!)).toHaveLength(1);
295
+ expect(document.querySelector('#untouched')?.className).toBe('');
296
+ expect([...css]).toEqual([theme_hash]);
297
+ expect(getCss(css)).toContain(`div.${theme_hash}`);
298
+ });
299
+ });
@@ -0,0 +1,106 @@
1
+ import ts from 'typescript';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ /**
5
+ * @param {string} source
6
+ * @param {boolean} automatic
7
+ */
8
+ function create_service(source, automatic) {
9
+ const root = process.cwd();
10
+ const file_name = `${root}/packages/ripple/jsx-types-test.tsx`;
11
+ const options = {
12
+ strict: true,
13
+ target: ts.ScriptTarget.ESNext,
14
+ module: ts.ModuleKind.ESNext,
15
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
16
+ jsx: ts.JsxEmit.Preserve,
17
+ ...(automatic ? { jsxImportSource: 'ripple' } : {}),
18
+ skipLibCheck: true,
19
+ types: [],
20
+ paths: {
21
+ '#public': [`${root}/packages/ripple/types/index.d.ts`],
22
+ ripple: [`${root}/packages/ripple/types/index.d.ts`],
23
+ 'ripple/jsx-runtime': [`${root}/packages/ripple/src/jsx-runtime.d.ts`],
24
+ },
25
+ };
26
+ const service = ts.createLanguageService({
27
+ getCompilationSettings: () => options,
28
+ getScriptFileNames: () => [file_name],
29
+ getScriptVersion: () => '0',
30
+ getScriptSnapshot: (name) => {
31
+ const text = name === file_name ? source : ts.sys.readFile(name);
32
+ return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
33
+ },
34
+ getCurrentDirectory: () => root,
35
+ getDefaultLibFileName: ts.getDefaultLibFilePath,
36
+ realpath: ts.sys.realpath,
37
+ directoryExists: ts.sys.directoryExists,
38
+ getDirectories: ts.sys.getDirectories,
39
+ fileExists: (name) => name === file_name || ts.sys.fileExists(name),
40
+ readFile: (name) => (name === file_name ? source : ts.sys.readFile(name)),
41
+ });
42
+ return { service, file_name };
43
+ }
44
+
45
+ describe('Ripple JSX types', () => {
46
+ it.each([true, false])('qualifies HTML tag hovers (automatic runtime: %s)', (automatic) => {
47
+ const source = `
48
+ import 'ripple/jsx-runtime';
49
+ const paragraph = <p />;
50
+ const input = <input />;
51
+ `;
52
+ const { service, file_name } = create_service(source, automatic);
53
+ try {
54
+ expect(service.getSemanticDiagnostics(file_name)).toEqual([]);
55
+ for (const [tag, attributes, element] of [
56
+ ['p', 'HTMLAttributes', 'HTMLParagraphElement'],
57
+ ['input', 'InputHTMLAttributes', 'HTMLInputElement'],
58
+ ]) {
59
+ const info = service.getQuickInfoAtPosition(file_name, source.indexOf(`<${tag} `) + 1);
60
+ expect(ts.displayPartsToString(info?.displayParts)).toBe(
61
+ `(property) Ripple.JSX.IntrinsicElements.${tag}: Ripple.DetailedHTMLProps<Ripple.${attributes}<${element}>, ${element}>`,
62
+ );
63
+ }
64
+ } finally {
65
+ service.dispose();
66
+ }
67
+ });
68
+
69
+ it('preserves JSX imports, global types, native events, and DOM refs', () => {
70
+ const source = `
71
+ import type { ClassValue, JSX as RuntimeJSX, Ripple } from 'ripple/jsx-runtime';
72
+ import { createRefKey, type RefKey } from 'ripple';
73
+
74
+ const ref_key: RefKey = createRefKey();
75
+ const props: Ripple.DetailedHTMLProps<Ripple.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> = {
76
+ value: 'hello',
77
+ ref: (node) => { node.select(); },
78
+ [ref_key]: (node) => { node.select(); },
79
+ onInput(event) { const input: HTMLInputElement = event.currentTarget; input.select(); },
80
+ onClick: { handleEvent(event) { event.currentTarget.select(); } },
81
+ };
82
+ const global_props: JSX.IntrinsicElements['input'] = props;
83
+ const runtime_props: RuntimeJSX.IntrinsicElements['input'] = global_props;
84
+ const classes: ClassValue = ['one', { two: true }];
85
+ const input = <input {...runtime_props} class={classes} />;
86
+ const svg = <svg><circle cx={10} ref={(node) => { const circle: SVGCircleElement = node; }} /></svg>;
87
+ // @ts-expect-error Input values do not accept objects.
88
+ const bad_value = <input value={{}} />;
89
+ // @ts-expect-error Refs remain specific to the element.
90
+ const bad_ref = <p ref={(node: HTMLInputElement) => { node.select(); }} />;
91
+ // @ts-expect-error Event currentTarget remains specific to the element.
92
+ const bad_event = <p onClick={(event) => { event.currentTarget.select(); }} />;
93
+ `;
94
+ const { service, file_name } = create_service(source, true);
95
+ try {
96
+ const diagnostics = service.getSemanticDiagnostics(file_name);
97
+ expect(
98
+ diagnostics.map((diagnostic) =>
99
+ ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
100
+ ),
101
+ ).toEqual([]);
102
+ } finally {
103
+ service.dispose();
104
+ }
105
+ });
106
+ });