ripple 0.4.2 → 0.4.3

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 (94) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/package.json +9 -3
  3. package/src/jsx-runtime.d.ts +2 -1
  4. package/src/runtime/array.js +15 -9
  5. package/src/runtime/index-client.js +25 -7
  6. package/src/runtime/index-server.js +1 -0
  7. package/src/runtime/internal/client/attributes.js +313 -0
  8. package/src/runtime/internal/client/blocks.js +4 -14
  9. package/src/runtime/internal/client/component.js +2 -5
  10. package/src/runtime/internal/client/composite.js +14 -6
  11. package/src/runtime/internal/client/constants.js +6 -6
  12. package/src/runtime/internal/client/context.js +3 -2
  13. package/src/runtime/internal/client/css.js +11 -26
  14. package/src/runtime/internal/client/errors.js +188 -0
  15. package/src/runtime/internal/client/events.js +79 -34
  16. package/src/runtime/internal/client/expression.js +21 -116
  17. package/src/runtime/internal/client/for.js +108 -232
  18. package/src/runtime/internal/client/head.js +4 -3
  19. package/src/runtime/internal/client/html.js +5 -3
  20. package/src/runtime/internal/client/hydrate.js +484 -0
  21. package/src/runtime/internal/client/hydration-enabled.js +7 -0
  22. package/src/runtime/internal/client/hydration.js +16 -81
  23. package/src/runtime/internal/client/if.js +13 -9
  24. package/src/runtime/internal/client/index.js +14 -6
  25. package/src/runtime/internal/client/operations.js +11 -7
  26. package/src/runtime/internal/client/portal.js +3 -2
  27. package/src/runtime/internal/client/render.js +42 -325
  28. package/src/runtime/internal/client/root-boundary-enabled.js +7 -0
  29. package/src/runtime/internal/client/runtime.js +87 -363
  30. package/src/runtime/internal/client/template-ns.js +47 -0
  31. package/src/runtime/internal/client/template.js +63 -140
  32. package/src/runtime/internal/client/track-async.js +356 -0
  33. package/src/runtime/internal/client/try.js +133 -319
  34. package/src/runtime/internal/client/types.d.ts +48 -24
  35. package/src/runtime/internal/server/index.js +31 -29
  36. package/src/runtime/internal/server/prerender.js +27 -0
  37. package/src/server/index.js +1 -0
  38. package/src/utils/class-name.js +28 -0
  39. package/tests/client/basic/basic.errors.test.tsrx +3 -3
  40. package/tests/client/compiler/__snapshots__/compiler.typescript.test.tsrx.snap +0 -2
  41. package/tests/client/compiler/compiler.basic.test.tsrx +2 -2
  42. package/tests/client/compiler/compiler.tracked-access.test.tsrx +3 -3
  43. package/tests/client/composite/composite.props.test.tsrx +10 -10
  44. package/tests/client/derived-release.test.tsrx +3 -3
  45. package/tests/client/for-item.test.tsrx +39 -0
  46. package/tests/client/for-selector.test.tsrx +95 -0
  47. package/tests/hydration/compiled/client/basic.js +87 -87
  48. package/tests/hydration/compiled/client/composite.js +5 -5
  49. package/tests/hydration/compiled/client/events.js +40 -48
  50. package/tests/hydration/compiled/client/for.js +162 -181
  51. package/tests/hydration/compiled/client/fragment-cursor.js +521 -289
  52. package/tests/hydration/compiled/client/head.js +22 -22
  53. package/tests/hydration/compiled/client/hmr.js +4 -4
  54. package/tests/hydration/compiled/client/html-in-template.js +5 -5
  55. package/tests/hydration/compiled/client/html.js +152 -162
  56. package/tests/hydration/compiled/client/if-children.js +58 -53
  57. package/tests/hydration/compiled/client/if-fragment-controlflow.js +39 -39
  58. package/tests/hydration/compiled/client/if.js +26 -26
  59. package/tests/hydration/compiled/client/mixed-control-flow.js +56 -68
  60. package/tests/hydration/compiled/client/nested-control-flow.js +139 -271
  61. package/tests/hydration/compiled/client/portal.js +10 -10
  62. package/tests/hydration/compiled/client/reactivity.js +29 -29
  63. package/tests/hydration/compiled/client/return.js +2 -2
  64. package/tests/hydration/compiled/client/streaming.js +33 -35
  65. package/tests/hydration/compiled/client/switch.js +25 -25
  66. package/tests/hydration/compiled/client/track-async-serialization.js +41 -41
  67. package/tests/hydration/compiled/client/try.js +15 -15
  68. package/tests/hydration/compiled/client/typed-text.js +7 -7
  69. package/tests/hydration/compiled/server/basic.js +6 -6
  70. package/tests/hydration/compiled/server/events.js +8 -8
  71. package/tests/hydration/compiled/server/for.js +22 -22
  72. package/tests/hydration/compiled/server/fragment-cursor.js +188 -52
  73. package/tests/hydration/compiled/server/head.js +6 -6
  74. package/tests/hydration/compiled/server/hmr.js +1 -1
  75. package/tests/hydration/compiled/server/html.js +1 -1
  76. package/tests/hydration/compiled/server/if-children.js +9 -9
  77. package/tests/hydration/compiled/server/if.js +5 -5
  78. package/tests/hydration/compiled/server/mixed-control-flow.js +4 -4
  79. package/tests/hydration/compiled/server/portal.js +1 -1
  80. package/tests/hydration/compiled/server/reactivity.js +9 -9
  81. package/tests/hydration/compiled/server/streaming.js +9 -9
  82. package/tests/hydration/compiled/server/switch.js +4 -4
  83. package/tests/hydration/compiled/server/track-async-serialization.js +16 -16
  84. package/tests/hydration/compiled/server/try.js +4 -4
  85. package/tests/hydration/compiled/server/typed-text.js +1 -1
  86. package/tests/hydration/components/fragment-cursor.tsrx +60 -0
  87. package/tests/hydration/components/track-async-serialization.tsrx +2 -2
  88. package/tests/hydration/fragment-cursor.test.js +34 -1
  89. package/tests/hydration/track-async-serialization.test.js +1 -1
  90. package/tests/server/basic.components.test.tsrx +3 -3
  91. package/tests/server/track-async-serialization.test.tsrx +6 -6
  92. package/tests/utils/tracked-types.test.js +3 -3
  93. package/types/index.d.ts +8 -8
  94. package/types/server.d.ts +17 -0
@@ -48,6 +48,11 @@ export type AppendIntoAnchor = {
48
48
  parent: Node;
49
49
  /** Marks the sentinel; a missing-property read on a DOM node is cheaper than `in`. */
50
50
  into: true;
51
+ /**
52
+ * The sentinel is the tail of `parent`: nothing the framework renders
53
+ * follows what it anchors, so an `@if` there never needs an anchor node.
54
+ */
55
+ tail: boolean;
51
56
  };
52
57
 
53
58
  export type Block = {
@@ -77,30 +82,50 @@ export type TryPendingFunction = (anchor: Node) => void;
77
82
  * helpers are module functions that compile only when a boundary uses them.
78
83
  */
79
84
  export type TryState = {
80
- anchor: Node;
81
- try_fn: (anchor: Node, block?: Block) => void;
82
- catch_fn: TryCatchFunction | null;
83
- pending_fn: TryPendingFunction | null;
85
+ /** anchor */
86
+ a: Node;
87
+ /** try function */
88
+ fn: (anchor: Node, block?: Block) => void;
89
+ /** catch function */
90
+ c: TryCatchFunction | null;
91
+ /** pending function */
92
+ p: TryPendingFunction | null;
84
93
  /** the catch branch's `reset` callback, created on first use */
85
- reset: (() => void) | null;
86
- pending_count: number;
87
- request_version: number;
88
- active_requests: Set<number>;
89
- try_block: Block | null;
90
- resolved_branch: Block | null;
91
- pending_branch: Block | null;
92
- catch_branch: Block | null;
93
- offscreen_fragment: DocumentFragment | null;
94
- has_resolved: boolean;
95
- mode: 'resolved' | 'pending' | 'catch';
96
- pending_deferreds: Map<number, (reason: any) => void>;
97
- paused_blocks: Set<Block>;
94
+ r: (() => void) | null;
95
+ /** pending request count */
96
+ n: number;
97
+ /** request version */
98
+ v: number;
99
+ /** active request ids */
100
+ q: Set<number>;
101
+ /** the try block */
102
+ b: Block | null;
103
+ /** resolved branch */
104
+ rb: Block | null;
105
+ /** pending branch */
106
+ pb: Block | null;
107
+ /** catch branch */
108
+ cb: Block | null;
109
+ /** fragment holding the resolved branch while it is offscreen */
110
+ o: DocumentFragment | null;
111
+ /** has resolved */
112
+ h: boolean;
113
+ /** mode: 0 resolved, 1 pending, 2 catch */
114
+ m: 0 | 1 | 2;
115
+ /** deferred rejections by request id */
116
+ d: Map<number, (reason: any) => void>;
117
+ /** blocks paused on a pending read */
118
+ z: Set<Block>;
98
119
  /** a streamed slot this boundary hydrated, until its chunk activates it */
99
- streamed_id: string | null;
100
- streamed_errored: boolean;
101
- streamed_fallback: boolean;
102
- slot_open: Comment | null;
103
- slot_close: Comment | null;
120
+ si: string | null;
121
+ /** the streamed slot errored */
122
+ se: boolean;
123
+ /** the streamed slot carries a hydratable fallback */
124
+ sf: boolean;
125
+ /** streamed slot open marker */
126
+ so: Comment | null;
127
+ /** streamed slot close marker */
128
+ sc: Comment | null;
104
129
  };
105
130
 
106
131
  export type BlockWithTryBoundary = Omit<Block, 's'> & {
@@ -108,7 +133,7 @@ export type BlockWithTryBoundary = Omit<Block, 's'> & {
108
133
  };
109
134
 
110
135
  export type BlockWithTryBoundaryAndCatch = Omit<BlockWithTryBoundary, 's'> & {
111
- s: TryState & { catch_fn: TryCatchFunction };
136
+ s: TryState & { c: TryCatchFunction };
112
137
  };
113
138
 
114
139
  export type RootBoundaryOptions = {
@@ -144,7 +169,6 @@ declare global {
144
169
 
145
170
  interface Element {
146
171
  __attributes?: {
147
- checked?: boolean;
148
172
  value?: string;
149
173
  };
150
174
  __click?: () => void;
@@ -60,7 +60,7 @@ import {
60
60
  is_boolean_attribute,
61
61
  normalize_css_property_name,
62
62
  } from '@tsrx/core/runtime/html';
63
- import { clsx } from 'clsx';
63
+ import { class_name } from '../../../utils/class-name.js';
64
64
  import { create_ref_prop } from '@tsrx/core/runtime/ref';
65
65
  import {
66
66
  BLOCK_CLOSE,
@@ -1599,7 +1599,7 @@ export function attr(name, value, is_boolean = false) {
1599
1599
  }
1600
1600
  if (value == null || (!value && is_boolean)) return '';
1601
1601
  const normalized = (name in replacements && replacements[name].get(value)) || value;
1602
- let value_to_escape = name === 'class' ? clsx(normalized) : normalized;
1602
+ let value_to_escape = name === 'class' ? class_name(normalized) : normalized;
1603
1603
  value_to_escape =
1604
1604
  name === 'style'
1605
1605
  ? typeof value !== 'string'
@@ -1719,15 +1719,6 @@ class TrackedValue {
1719
1719
  set value(v) {
1720
1720
  set(/** @type {Tracked} */ (this), v);
1721
1721
  }
1722
- /**
1723
- * A read-only view: a derived over this value, for a receiver that should
1724
- * read but not write it. Equivalent to `track(() => tracked.value)`. The
1725
- * view has no hash of its own: it is not a serialized value.
1726
- * @returns {Derived}
1727
- */
1728
- readOnly() {
1729
- return derived(() => get_tracked(/** @type {Tracked} */ (this)));
1730
- }
1731
1722
  }
1732
1723
 
1733
1724
  class DerivedValue {
@@ -1765,17 +1756,6 @@ class DerivedValue {
1765
1756
  set value(v) {
1766
1757
  set(/** @type {Derived} */ (this), v);
1767
1758
  }
1768
- /**
1769
- * A read-only view (see `TrackedValue#readOnly`). A derived without a
1770
- * setter is already read-only and is returned as is; a writable one is
1771
- * wrapped in a derived that follows it.
1772
- * @returns {Derived}
1773
- */
1774
- readOnly() {
1775
- return this.a.set === undefined
1776
- ? /** @type {Derived} */ (this)
1777
- : derived(() => get_derived(/** @type {Derived} */ (this)));
1778
- }
1779
1759
  }
1780
1760
 
1781
1761
  /**
@@ -1804,6 +1784,25 @@ function derived(v, hash, get, set) {
1804
1784
  );
1805
1785
  }
1806
1786
 
1787
+ /**
1788
+ * A read-only view of a tracked or derived value (see the client runtime's
1789
+ * `track_read_only`). The view has no hash of its own: it is not a serialized
1790
+ * value.
1791
+ * @param {any} value
1792
+ * @returns {any}
1793
+ */
1794
+ export function track_read_only(value) {
1795
+ if (!is_ripple_object(value)) {
1796
+ return value;
1797
+ }
1798
+ if ((value.f & DERIVED) !== 0) {
1799
+ var d = /** @type {Derived} */ (value);
1800
+ return d.a.set === undefined ? d : derived(() => get_derived(d));
1801
+ }
1802
+ var t = /** @type {Tracked} */ (value);
1803
+ return derived(() => get_tracked(t));
1804
+ }
1805
+
1807
1806
  /**
1808
1807
  * @param {any} v
1809
1808
  * @param {string} hash
@@ -2278,7 +2277,7 @@ function run_track_async(t, fn, block, dr, dj) {
2278
2277
  /**
2279
2278
  * The hashes of the compile-time tracked values a trackAsync read, recorded
2280
2279
  * in its hydration envelope so the client re-subscribes to them. A dependency
2281
- * without a hash that is itself a derived (a `readOnly()` view, a derived made
2280
+ * without a hash that is itself a derived (a `trackReadOnly()` view, a derived made
2282
2281
  * outside a compiled call) stands for what it read: its own dependencies are
2283
2282
  * recorded in its place.
2284
2283
  * @param {Dependency | null} head
@@ -2530,35 +2529,38 @@ export function ripple_array(...elements) {
2530
2529
  }
2531
2530
 
2532
2531
  /**
2532
+ * `RippleArray.from(...)`
2533
2533
  * @template T
2534
2534
  * @param {ArrayLike<T> | Iterable<T>} arrayLike
2535
2535
  * @param {(v: T, k: number) => any | undefined} [map_fn]
2536
2536
  * @param {any} [thisArg]
2537
2537
  * @returns {T[]}
2538
2538
  */
2539
- ripple_array.from = function (arrayLike, map_fn, thisArg) {
2539
+ export function ripple_array_from(arrayLike, map_fn, thisArg) {
2540
2540
  return map_fn ? Array.from(arrayLike, map_fn, thisArg) : Array.from(arrayLike);
2541
- };
2541
+ }
2542
2542
 
2543
2543
  /**
2544
+ * `RippleArray.of(...)`
2544
2545
  * @template T
2545
2546
  * @param {...T} items
2546
2547
  * @returns {T[]}
2547
2548
  */
2548
- ripple_array.of = function (...items) {
2549
+ export function ripple_array_of(...items) {
2549
2550
  return Array.of(...items);
2550
- };
2551
+ }
2551
2552
 
2552
2553
  /**
2554
+ * `RippleArray.fromAsync(...)`
2553
2555
  * @template T
2554
2556
  * @param {ArrayLike<T> | Iterable<T>} arrayLike
2555
2557
  * @param {(v: T, k: number) => any | undefined} [map_fn]
2556
2558
  * @param {any} [thisArg]
2557
2559
  * @returns {Promise<T[]>}
2558
2560
  */
2559
- ripple_array.from_async = async function (arrayLike, map_fn, thisArg) {
2561
+ export async function ripple_array_from_async(arrayLike, map_fn, thisArg) {
2560
2562
  return map_fn ? Array.fromAsync(arrayLike, map_fn, thisArg) : Array.fromAsync(arrayLike);
2561
- };
2563
+ }
2562
2564
 
2563
2565
  /**
2564
2566
  * @param {object} obj
@@ -0,0 +1,27 @@
1
+ /** @import { RenderComponent } from './index.js' */
2
+ /** @import { RenderResult } from 'ripple/server' */
3
+ /** @import { RootBoundaryOptions } from '#public' */
4
+
5
+ import { render } from './index.js';
6
+ import { get_css_text } from './css-registry.js';
7
+
8
+ /**
9
+ * Renders a component to static HTML: buffered, every `@try` boundary and
10
+ * `trackAsync` settled before it resolves, with the scoped CSS as text. The
11
+ * static-generation counterpart of `render()`, for HTML written at build time
12
+ * that `hydrate()` picks up like any server-rendered page.
13
+ * @param {RenderComponent} component
14
+ * @param {{ rootBoundary?: RootBoundaryOptions }} [options]
15
+ * @returns {Promise<{ head: string, body: string, css: string, topLevelError: Error | null }>}
16
+ */
17
+ export async function prerender(component, options = {}) {
18
+ const result = /** @type {RenderResult} */ (
19
+ await render(component, { rootBoundary: options.rootBoundary })
20
+ );
21
+ return {
22
+ head: result.head,
23
+ body: result.body,
24
+ css: get_css_text(result.css),
25
+ topLevelError: result.topLevelError ?? null,
26
+ };
27
+ }
@@ -1,6 +1,7 @@
1
1
  // SSR helpers
2
2
  export { create_ssr_stream as createStream, render } from '../runtime/internal/server/index.js';
3
3
  export { get_css_text as getCss } from '../runtime/internal/server/css-registry.js';
4
+ export { prerender } from '../runtime/internal/server/prerender.js';
4
5
  export { executeServerFunction } from '../runtime/internal/server/rpc.js';
5
6
 
6
7
  // Re-export server runtime for components compiled for SSR
@@ -0,0 +1,28 @@
1
+ /**
2
+ * clsx-style class composition, shared by the client and server renderers: a
3
+ * string as it is, a truthy number, the truthy items of an array, the keys of
4
+ * an object with truthy values, nested arrays flattened, everything else
5
+ * dropped.
6
+ * @param {any} value
7
+ * @returns {string}
8
+ */
9
+ export function class_name(value) {
10
+ if (typeof value === 'string') {
11
+ return value;
12
+ }
13
+ if (typeof value !== 'object' || value === null) {
14
+ return typeof value === 'number' && value ? '' + value : '';
15
+ }
16
+ var str = '';
17
+ if (Array.isArray(value)) {
18
+ for (var i = 0; i < value.length; i++) {
19
+ var item = value[i] && class_name(value[i]);
20
+ if (item) str = str ? str + ' ' + item : item;
21
+ }
22
+ } else {
23
+ for (var key in value) {
24
+ if (value[key]) str = str ? str + ' ' + key : key;
25
+ }
26
+ }
27
+ return str;
28
+ }
@@ -126,7 +126,7 @@ describe('basic client > errors', () => {
126
126
 
127
127
  render(Basic);
128
128
 
129
- expect(error).toBe(
129
+ expect(error).toContain(
130
130
  'Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation',
131
131
  );
132
132
  });
@@ -148,7 +148,7 @@ describe('basic client > errors', () => {
148
148
 
149
149
  render(Basic);
150
150
 
151
- expect(error).toBe(
151
+ expect(error).toContain(
152
152
  'Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation',
153
153
  );
154
154
  });
@@ -169,7 +169,7 @@ describe('basic client > errors', () => {
169
169
 
170
170
  render(Basic);
171
171
 
172
- expect(error).toBe(
172
+ expect(error).toContain(
173
173
  'Assignments or updates to tracked values are not allowed during computed "track(() => ...)" evaluation',
174
174
  );
175
175
  });
@@ -30,8 +30,6 @@ exports[`compiler > typescript > removes class extends type arguments from JS ou
30
30
 
31
31
  class StringMap extends Map {
32
32
  constructor() {
33
- var __block = _$_.scope();
34
-
35
33
  super();
36
34
  }
37
35
  }"
@@ -213,8 +213,8 @@ export function App() @{
213
213
 
214
214
  // Already a string: no String(… ?? '') double wrap around it, and the
215
215
  // scope wrapper sits on the user call, not on the coercion.
216
- expect(code).toContain('String(_$_.with_scope(__prev.___block, fetchLabel))');
217
- expect(code).not.toContain('_$_.with_scope(__prev.___block, () => String(');
216
+ expect(code).toMatch(/String\(_\$_\.with_scope\(__prev\._[a-z]+, fetchLabel\)\)/);
217
+ expect(code).not.toMatch(/_\$_\.with_scope\(__prev\._[a-z]+, \(\) => String\(/);
218
218
  expect(code).not.toContain('String(String(');
219
219
  expect(code).not.toContain('?? \'\'');
220
220
  });
@@ -5,7 +5,7 @@ const value_message = /Accessing a tracked object directly is not allowed, use `
5
5
  describe('Compiler: Tracked Object Direct Access Checks', () => {
6
6
  it('should error on direct access to __v of a tracked object', () => {
7
7
  const code = `
8
- import { track } from 'ripple';
8
+ import { track, trackReadOnly } from 'ripple';
9
9
  export default function App() @{
10
10
  let count = track(0);
11
11
  console.log(count.__v);
@@ -200,12 +200,12 @@ import { track } from 'ripple';
200
200
  expect(() => compile(code, 'test.tsrx')).not.toThrow();
201
201
  });
202
202
 
203
- it('should allow .readOnly() on a tracked object', () => {
203
+ it('should allow .trackReadOnly() on a tracked object', () => {
204
204
  const code = `
205
205
  import { track } from 'ripple';
206
206
  export default function App() @{
207
207
  let count = track(0);
208
- const view = count.readOnly();
208
+ const view = trackReadOnly(count);
209
209
  <>{view.value}</>
210
210
  }
211
211
  `;
@@ -1,8 +1,8 @@
1
1
  import type { Derived, Tracked, Props } from 'ripple';
2
- import { effect, flushSync, track } from 'ripple';
2
+ import { effect, flushSync, track, trackReadOnly } from 'ripple';
3
3
 
4
4
  describe('composite > props', () => {
5
- it('hands a child a read-only view through readOnly()', () => {
5
+ it('hands a child a read-only view through trackReadOnly()', () => {
6
6
  function Child({ count }: { count: Derived<number> }) @{
7
7
  <span class="child">{count.value}</span>
8
8
  }
@@ -10,7 +10,7 @@ describe('composite > props', () => {
10
10
  function App() @{
11
11
  const count = track(0);
12
12
  <>
13
- <Child count={count.readOnly()} />
13
+ <Child count={trackReadOnly(count)} />
14
14
  <button onClick={() => count.value++}>{'inc'}</button>
15
15
  </>
16
16
  }
@@ -24,13 +24,13 @@ describe('composite > props', () => {
24
24
  expect(container.querySelector('.child').textContent).toBe('1');
25
25
  });
26
26
 
27
- it('returns a read-only derived from readOnly() as is and wraps a writable one', () => {
27
+ it('returns a read-only derived from trackReadOnly() as is and wraps a writable one', () => {
28
28
  function App() @{
29
29
  const count = track(1);
30
30
  const double = track(() => count.value * 2);
31
31
  const draft = track(() => count.value, undefined, true);
32
- const double_view = double.readOnly();
33
- const draft_view = draft.readOnly();
32
+ const double_view = trackReadOnly(double);
33
+ const draft_view = trackReadOnly(draft);
34
34
  expect(double_view).toBe(double);
35
35
  expect(draft_view).not.toBe(draft);
36
36
  <>
@@ -51,7 +51,7 @@ describe('composite > props', () => {
51
51
  expect(container.querySelector('.view').textContent).toBe('42');
52
52
  });
53
53
 
54
- it('releases a readOnly() view with the block that created it', () => {
54
+ it('releases a trackReadOnly() view with the block that created it', () => {
55
55
  let set_rows: (rows: number[]) => void = () => {};
56
56
  let bump: () => void = () => {};
57
57
  let source: Tracked<number> | undefined;
@@ -73,7 +73,7 @@ describe('composite > props', () => {
73
73
  <ul>
74
74
  @for (const row of rows.value; key row) {
75
75
  <li>
76
- <Child count={count.readOnly()} />
76
+ <Child count={trackReadOnly(count)} />
77
77
  </li>
78
78
  }
79
79
  </ul>
@@ -106,12 +106,12 @@ describe('composite > props', () => {
106
106
  expect(subscribers()).toBe(0);
107
107
  });
108
108
 
109
- it('keeps a readOnly() view from writing through to its source', () => {
109
+ it('keeps a trackReadOnly() view from writing through to its source', () => {
110
110
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
111
111
  try {
112
112
  function App() @{
113
113
  const count = track(1);
114
- const view = count.readOnly();
114
+ const view = trackReadOnly(count);
115
115
  const write = () => {
116
116
  view.value = 99;
117
117
  };
@@ -1,5 +1,5 @@
1
1
  import type { Derived, Tracked } from 'ripple';
2
- import { effect, flushSync, track } from 'ripple';
2
+ import { effect, flushSync, track, trackReadOnly } from 'ripple';
3
3
 
4
4
  /** The runtime's own subscriber list of a tracked value (`sb` / `sn`). */
5
5
  function subscribers(source: Tracked<number>) {
@@ -34,7 +34,7 @@ describe('derived release', () => {
34
34
  const label = track(() => 'n=' + count.value);
35
35
  <>
36
36
  <span class="label">{label.value}</span>
37
- <Child count={count.readOnly()} />
37
+ <Child count={trackReadOnly(count)} />
38
38
  </>
39
39
  }
40
40
  </>
@@ -73,7 +73,7 @@ describe('derived release', () => {
73
73
  <ul>
74
74
  @for (const row of rows.value; key row) {
75
75
  <li>
76
- <Child count={count.readOnly()} />
76
+ <Child count={trackReadOnly(count)} />
77
77
  </li>
78
78
  }
79
79
  </ul>
@@ -270,6 +270,45 @@ describe('trailing control flow appends into the parent', () => {
270
270
  expect(div.lastElementChild?.localName).toBe('span');
271
271
  });
272
272
 
273
+ it('leaves no anchor node behind a trailing @if', () => {
274
+ let set: (value: boolean) => void = () => {};
275
+
276
+ function App() @{
277
+ const open = track(false);
278
+ set = (value) => {
279
+ open.value = value;
280
+ };
281
+ <div>
282
+ <b>{'head'}</b>
283
+ @if (open.value) {
284
+ <span>{'open'}</span>
285
+ }
286
+ </div>
287
+ }
288
+
289
+ render(App);
290
+ flushSync();
291
+ const div = container.querySelector('div')!;
292
+ // The if is the div's tail: nothing follows it, so it needs no anchor,
293
+ // off or on.
294
+ expect(div.childNodes.length).toBe(1);
295
+
296
+ set(true);
297
+ flushSync();
298
+ expect(div.innerHTML).toBe('<b>head</b><span>open</span>');
299
+ expect(div.childNodes.length).toBe(2);
300
+
301
+ set(false);
302
+ flushSync();
303
+ expect(div.innerHTML).toBe('<b>head</b>');
304
+ expect(div.childNodes.length).toBe(1);
305
+
306
+ set(true);
307
+ flushSync();
308
+ expect(div.innerHTML).toBe('<b>head</b><span>open</span>');
309
+ expect(div.childNodes.length).toBe(2);
310
+ });
311
+
273
312
  it('keeps a trailing @for with @empty in place', () => {
274
313
  let set: (items: string[]) => void = () => {};
275
314
 
@@ -219,3 +219,98 @@ describe('keyed reconciliation with most items moving', () => {
219
219
  expect(after.map((el) => el.textContent)).toEqual(order.map(String));
220
220
  });
221
221
  });
222
+
223
+ // An `@if (outer === item.key)` in a keyed loop body lowers to the same
224
+ // selector as an attribute comparison would, reading the block's key: a
225
+ // change of the outer value re-runs the ifs of its previous and next key
226
+ // alone, and a replaced item never re-runs its if.
227
+ describe('@if selector comparisons', () => {
228
+ interface Item {
229
+ id: number;
230
+ label: string;
231
+ }
232
+ let select: (id: number | undefined) => void = () => {};
233
+ let setItems: (items: Item[]) => void = () => {};
234
+
235
+ function App() @{
236
+ const items = track<Item[]>([
237
+ { id: 1, label: 'one' },
238
+ { id: 2, label: 'two' },
239
+ { id: 3, label: 'three' },
240
+ ]);
241
+ const selected = track<number | undefined>(undefined);
242
+ select = (id) => {
243
+ selected.value = id;
244
+ };
245
+ setItems = (next) => {
246
+ items.value = next;
247
+ };
248
+
249
+ <ul>
250
+ @for (const item of items.value; key item.id) {
251
+ <li data-id={item.id}>
252
+ <span>{item.label}</span>
253
+ @if (selected.value === item.id) {
254
+ <b>{'current'}</b>
255
+ }
256
+ </li>
257
+ }
258
+ </ul>
259
+ }
260
+
261
+ function marked(): string[] {
262
+ return Array.from(container.querySelectorAll('li')).map(
263
+ (li) => `${li.getAttribute('data-id')}:${li.textContent}`,
264
+ );
265
+ }
266
+
267
+ it('moves the marker between rows and keeps it on a replaced row', () => {
268
+ render(App);
269
+ flushSync();
270
+ expect(marked()).toEqual(['1:one', '2:two', '3:three']);
271
+
272
+ select(2);
273
+ flushSync();
274
+ expect(marked()).toEqual(['1:one', '2:twocurrent', '3:three']);
275
+ const b = container.querySelector('b')!;
276
+
277
+ select(3);
278
+ flushSync();
279
+ expect(marked()).toEqual(['1:one', '2:two', '3:threecurrent']);
280
+
281
+ // The selected row's object is replaced under its key: the label
282
+ // updates and the marker stays, in place behind the label.
283
+ setItems([
284
+ { id: 1, label: 'uno' },
285
+ { id: 2, label: 'dos' },
286
+ { id: 3, label: 'tres' },
287
+ ]);
288
+ flushSync();
289
+ expect(marked()).toEqual(['1:uno', '2:dos', '3:trescurrent']);
290
+ expect(container.querySelector('li:last-child')!.childNodes.length).toBe(2);
291
+ expect(container.querySelector('b')).not.toBe(b);
292
+
293
+ select(undefined);
294
+ flushSync();
295
+ expect(marked()).toEqual(['1:uno', '2:dos', '3:tres']);
296
+ // Off again, and no anchor node is left behind the label.
297
+ for (const li of container.querySelectorAll('li')) {
298
+ expect(li.childNodes.length).toBe(1);
299
+ }
300
+ });
301
+
302
+ it('marks a row created after the selection was set', () => {
303
+ render(App);
304
+ flushSync();
305
+ select(4);
306
+ flushSync();
307
+ expect(marked()).toEqual(['1:one', '2:two', '3:three']);
308
+
309
+ setItems([
310
+ { id: 4, label: 'four' },
311
+ { id: 1, label: 'one' },
312
+ ]);
313
+ flushSync();
314
+ expect(marked()).toEqual(['4:fourcurrent', '1:one']);
315
+ });
316
+ });