@c9up/aurora 0.1.31 → 0.1.32
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/dist/AuroraManager.js +15 -4
- package/dist/hydrate.js +70 -15
- package/dist/reactive.js +4 -1
- package/dist/render.js +2 -1
- package/package.json +1 -1
- package/src/AuroraManager.ts +15 -4
- package/src/hydrate.ts +65 -19
- package/src/reactive.ts +4 -1
- package/src/render.ts +2 -1
package/dist/AuroraManager.js
CHANGED
|
@@ -90,11 +90,22 @@ export class AuroraManager {
|
|
|
90
90
|
shared: mergeSharedResolvers(this.shared, options?.shared),
|
|
91
91
|
importmap: {
|
|
92
92
|
"@c9up/aurora": `${this.auroraAssetPath}/index.js`,
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
93
|
+
// Every browser-facing subpath needs an explicit entry — importmaps
|
|
94
|
+
// don't read package `exports`, and an extensionless bare specifier
|
|
95
|
+
// won't hit a trailing-slash prefix map. Served from the same aurora
|
|
96
|
+
// dist; harmless when a page never imports one.
|
|
97
|
+
//
|
|
98
|
+
// `relay` is the one that has to be here: unlike `hydrate` and
|
|
99
|
+
// `ssr` it is NOT re-exported from the barrel, so a page that wants
|
|
100
|
+
// the relay client has no other specifier to reach it by.
|
|
101
|
+
//
|
|
102
|
+
// The node-only subpaths (`provider`, `server`, `services/main`)
|
|
103
|
+
// are deliberately absent: mapping them would hand the browser a
|
|
104
|
+
// specifier that resolves to a module it cannot load.
|
|
97
105
|
"@c9up/aurora/rpc": `${this.auroraAssetPath}/rpc.js`,
|
|
106
|
+
"@c9up/aurora/relay": `${this.auroraAssetPath}/relay.js`,
|
|
107
|
+
"@c9up/aurora/hydrate": `${this.auroraAssetPath}/hydrate.js`,
|
|
108
|
+
"@c9up/aurora/ssr": `${this.auroraAssetPath}/ssr.js`,
|
|
98
109
|
// Auto-map @c9up/comet when installed so the rpc client's bare
|
|
99
110
|
// `import '@c9up/comet'` resolves in the no-bundler browser — no
|
|
100
111
|
// app-side importmap wiring. Omitted when comet isn't present.
|
package/dist/hydrate.js
CHANGED
|
@@ -177,21 +177,76 @@ function liveNodeCount(value) {
|
|
|
177
177
|
}
|
|
178
178
|
return 1; // scalar → one inlined text node
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The first and last top-level nodes an item contributes to the live DOM, as
|
|
182
|
+
* node types.
|
|
183
|
+
*
|
|
184
|
+
* Needed because the browser MERGES adjacent text nodes when it parses the SSR
|
|
185
|
+
* HTML. Two items whose markup touches — one ending in text, the next starting
|
|
186
|
+
* with text — share a single live node at their boundary, and counting them
|
|
187
|
+
* separately shifts every following item by one. That is why a list of one
|
|
188
|
+
* hydrates and a list of two does not: with one item there is no boundary.
|
|
189
|
+
*
|
|
190
|
+
* Read from the parsed TEMPLATE, which is what both sides agree on: a slot that
|
|
191
|
+
* renders to nothing still occupies a comment-marked range, and comments never
|
|
192
|
+
* merge with text.
|
|
193
|
+
*/
|
|
194
|
+
function edgeNodeTypes(value) {
|
|
195
|
+
if (value === null || value === undefined || value === false)
|
|
196
|
+
return null;
|
|
197
|
+
if (isTemplateResult(value)) {
|
|
198
|
+
const children = getTemplate(value.strings).element.content.childNodes;
|
|
199
|
+
const first = children[0];
|
|
200
|
+
const last = children[children.length - 1];
|
|
201
|
+
if (!first || !last)
|
|
202
|
+
return null;
|
|
203
|
+
return { first: first.nodeType, last: last.nodeType };
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
// A nested array's edges are its own first and last contributing items.
|
|
207
|
+
let first = null;
|
|
208
|
+
let last = null;
|
|
209
|
+
for (const v of value) {
|
|
210
|
+
const edges = edgeNodeTypes(v);
|
|
211
|
+
if (!edges)
|
|
212
|
+
continue;
|
|
213
|
+
if (first === null)
|
|
214
|
+
first = edges.first;
|
|
215
|
+
last = edges.last;
|
|
216
|
+
}
|
|
217
|
+
return first === null || last === null ? null : { first, last };
|
|
218
|
+
}
|
|
219
|
+
// Scalar — inlined as one text node, so it merges on both sides.
|
|
220
|
+
return { first: 3 /* Text */, last: 3 /* Text */ };
|
|
221
|
+
}
|
|
180
222
|
/**
|
|
181
223
|
* Hydrate the items of a reactive array against the SSR nodes inside its marker
|
|
182
224
|
* range. Each item is hydrated against its own slice of the (marker-collapsed)
|
|
183
225
|
* range, IN ORDER, so every item's inner marker pairs are consumed in document
|
|
184
|
-
* order and the global cursor stays aligned for slots AFTER the list.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
226
|
+
* order and the global cursor stays aligned for slots AFTER the list.
|
|
227
|
+
*
|
|
228
|
+
* Item templates used to need a shape — single root, no surrounding whitespace,
|
|
229
|
+
* no bare adjacent scalars — because the slice was a straight node count and
|
|
230
|
+
* the browser merges adjacent text nodes. A prettier-formatted item template
|
|
231
|
+
* was enough to break it, silently, from the second item onwards. The boundary
|
|
232
|
+
* is now accounted for (see `edgeNodeTypes`), so any item shape hydrates.
|
|
189
233
|
*/
|
|
190
234
|
function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor) {
|
|
191
235
|
const nodes = collapseMarkerRanges(rangeNodes);
|
|
192
236
|
let offset = 0;
|
|
237
|
+
/** The node type the previous item ended on, for the merge check below. */
|
|
238
|
+
let previousLast = null;
|
|
193
239
|
for (const item of items) {
|
|
194
240
|
const count = liveNodeCount(item);
|
|
241
|
+
const edges = edgeNodeTypes(item);
|
|
242
|
+
// Text-node merge at the item boundary: the previous item's trailing
|
|
243
|
+
// text and this one's leading text are ONE node in the live DOM, so this
|
|
244
|
+
// item starts where the previous one appeared to end. Without this the
|
|
245
|
+
// slice slides by one per boundary and every item after the first
|
|
246
|
+
// resolves its slot paths against the wrong nodes — reported as
|
|
247
|
+
// "slot 0 (attr) path 1.0 not found", once per slot per item.
|
|
248
|
+
if (previousLast === 3 && edges?.first === 3)
|
|
249
|
+
offset -= 1;
|
|
195
250
|
if (isTemplateResult(item)) {
|
|
196
251
|
hydrateTemplateResult(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
|
|
197
252
|
}
|
|
@@ -199,6 +254,10 @@ function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor
|
|
|
199
254
|
hydrateArrayItems(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
|
|
200
255
|
}
|
|
201
256
|
offset += count;
|
|
257
|
+
// An item that contributes nothing (null/false) leaves the boundary
|
|
258
|
+
// where the last CONTRIBUTING item put it.
|
|
259
|
+
if (edges)
|
|
260
|
+
previousLast = edges.last;
|
|
202
261
|
}
|
|
203
262
|
}
|
|
204
263
|
/**
|
|
@@ -255,13 +314,6 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
|
|
|
255
314
|
// Hydration walks via the SAME path resolver as render, but against
|
|
256
315
|
// a synthetic root that mimics the parsed template's child list.
|
|
257
316
|
const tpl = getTemplate(result.strings);
|
|
258
|
-
// The live container's children should structurally match the
|
|
259
|
-
// template's content children. Wrap them in a transient DocumentFragment
|
|
260
|
-
// for path resolution — DocumentFragment.childNodes is the same view
|
|
261
|
-
// we walked during parse.
|
|
262
|
-
const syntheticRoot = {
|
|
263
|
-
childNodes: liveNodes,
|
|
264
|
-
};
|
|
265
317
|
// An attribute interpolating several slots — `class="static ${a} ${b}"` — is
|
|
266
318
|
// ONE attribute value built from all of them plus the static segments in
|
|
267
319
|
// between. Binding each slot on its own would have the last writer win and
|
|
@@ -269,7 +321,7 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
|
|
|
269
321
|
const multiGroups = new Map();
|
|
270
322
|
for (let i = 0; i < tpl.slots.length; i++) {
|
|
271
323
|
const slot = tpl.slots[i];
|
|
272
|
-
const liveNode = resolvePathLive(
|
|
324
|
+
const liveNode = resolvePathLive(slot.path, liveNodes);
|
|
273
325
|
if (!liveNode) {
|
|
274
326
|
// Path missed in the live DOM — SSR markup diverges from the
|
|
275
327
|
// parsed template's shape. Surfacing the mismatch beats silent
|
|
@@ -363,7 +415,7 @@ function collapseMarkerRanges(nodes) {
|
|
|
363
415
|
}
|
|
364
416
|
return out;
|
|
365
417
|
}
|
|
366
|
-
function resolvePathLive(
|
|
418
|
+
function resolvePathLive(path, rootNodes) {
|
|
367
419
|
if (path.length === 0)
|
|
368
420
|
return null;
|
|
369
421
|
// Collapse marker ranges at EVERY level so the live child list matches the
|
|
@@ -568,7 +620,10 @@ function hydrateBooleanAttrSlot(slot, el, value, cleanups) {
|
|
|
568
620
|
}
|
|
569
621
|
function hydratePropSlot(slot, el, value, cleanups) {
|
|
570
622
|
function apply(v) {
|
|
571
|
-
|
|
623
|
+
// Reflect.set rather than a cast: writing an arbitrary property onto an
|
|
624
|
+
// element is exactly what Reflect is for, and it does not require
|
|
625
|
+
// claiming the element is something it is not.
|
|
626
|
+
Reflect.set(el, slot.name, v);
|
|
572
627
|
}
|
|
573
628
|
if (isSignal(value) || typeof value === "function") {
|
|
574
629
|
const dispose = effect(() => apply(value()));
|
package/dist/reactive.js
CHANGED
|
@@ -84,7 +84,10 @@ export function signal(initial, options) {
|
|
|
84
84
|
eff.run();
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
accessor
|
|
87
|
+
// The brand is stamped through Reflect: the accessor is a function, and
|
|
88
|
+
// saying it is a branded object to write one property would be a lie the
|
|
89
|
+
// rest of the file then has to work around.
|
|
90
|
+
Reflect.set(accessor, SIGNAL_BRAND, true);
|
|
88
91
|
signalNodes.set(accessor, node);
|
|
89
92
|
return accessor;
|
|
90
93
|
}
|
package/dist/render.js
CHANGED
|
@@ -307,7 +307,8 @@ function applyBooleanAttrSlot(slot, el, value, cleanups) {
|
|
|
307
307
|
}
|
|
308
308
|
function applyPropSlot(slot, el, value, cleanups) {
|
|
309
309
|
function apply(v) {
|
|
310
|
-
|
|
310
|
+
// See hydrate.ts: Reflect.set writes the property without a cast.
|
|
311
|
+
Reflect.set(el, slot.name, v);
|
|
311
312
|
}
|
|
312
313
|
if (isSignal(value) || typeof value === "function") {
|
|
313
314
|
const dispose = effect(() => apply(value()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/AuroraManager.ts
CHANGED
|
@@ -168,11 +168,22 @@ export class AuroraManager {
|
|
|
168
168
|
shared: mergeSharedResolvers(this.shared, options?.shared),
|
|
169
169
|
importmap: {
|
|
170
170
|
"@c9up/aurora": `${this.auroraAssetPath}/index.js`,
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
171
|
+
// Every browser-facing subpath needs an explicit entry — importmaps
|
|
172
|
+
// don't read package `exports`, and an extensionless bare specifier
|
|
173
|
+
// won't hit a trailing-slash prefix map. Served from the same aurora
|
|
174
|
+
// dist; harmless when a page never imports one.
|
|
175
|
+
//
|
|
176
|
+
// `relay` is the one that has to be here: unlike `hydrate` and
|
|
177
|
+
// `ssr` it is NOT re-exported from the barrel, so a page that wants
|
|
178
|
+
// the relay client has no other specifier to reach it by.
|
|
179
|
+
//
|
|
180
|
+
// The node-only subpaths (`provider`, `server`, `services/main`)
|
|
181
|
+
// are deliberately absent: mapping them would hand the browser a
|
|
182
|
+
// specifier that resolves to a module it cannot load.
|
|
175
183
|
"@c9up/aurora/rpc": `${this.auroraAssetPath}/rpc.js`,
|
|
184
|
+
"@c9up/aurora/relay": `${this.auroraAssetPath}/relay.js`,
|
|
185
|
+
"@c9up/aurora/hydrate": `${this.auroraAssetPath}/hydrate.js`,
|
|
186
|
+
"@c9up/aurora/ssr": `${this.auroraAssetPath}/ssr.js`,
|
|
176
187
|
// Auto-map @c9up/comet when installed so the rpc client's bare
|
|
177
188
|
// `import '@c9up/comet'` resolves in the no-bundler browser — no
|
|
178
189
|
// app-side importmap wiring. Omitted when comet isn't present.
|
package/src/hydrate.ts
CHANGED
|
@@ -248,15 +248,56 @@ function liveNodeCount(value: unknown): number {
|
|
|
248
248
|
return 1; // scalar → one inlined text node
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The first and last top-level nodes an item contributes to the live DOM, as
|
|
253
|
+
* node types.
|
|
254
|
+
*
|
|
255
|
+
* Needed because the browser MERGES adjacent text nodes when it parses the SSR
|
|
256
|
+
* HTML. Two items whose markup touches — one ending in text, the next starting
|
|
257
|
+
* with text — share a single live node at their boundary, and counting them
|
|
258
|
+
* separately shifts every following item by one. That is why a list of one
|
|
259
|
+
* hydrates and a list of two does not: with one item there is no boundary.
|
|
260
|
+
*
|
|
261
|
+
* Read from the parsed TEMPLATE, which is what both sides agree on: a slot that
|
|
262
|
+
* renders to nothing still occupies a comment-marked range, and comments never
|
|
263
|
+
* merge with text.
|
|
264
|
+
*/
|
|
265
|
+
function edgeNodeTypes(value: unknown): { first: number; last: number } | null {
|
|
266
|
+
if (value === null || value === undefined || value === false) return null;
|
|
267
|
+
if (isTemplateResult(value)) {
|
|
268
|
+
const children = getTemplate(value.strings).element.content.childNodes;
|
|
269
|
+
const first = children[0];
|
|
270
|
+
const last = children[children.length - 1];
|
|
271
|
+
if (!first || !last) return null;
|
|
272
|
+
return { first: first.nodeType, last: last.nodeType };
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(value)) {
|
|
275
|
+
// A nested array's edges are its own first and last contributing items.
|
|
276
|
+
let first: number | null = null;
|
|
277
|
+
let last: number | null = null;
|
|
278
|
+
for (const v of value) {
|
|
279
|
+
const edges = edgeNodeTypes(v);
|
|
280
|
+
if (!edges) continue;
|
|
281
|
+
if (first === null) first = edges.first;
|
|
282
|
+
last = edges.last;
|
|
283
|
+
}
|
|
284
|
+
return first === null || last === null ? null : { first, last };
|
|
285
|
+
}
|
|
286
|
+
// Scalar — inlined as one text node, so it merges on both sides.
|
|
287
|
+
return { first: 3 /* Text */, last: 3 /* Text */ };
|
|
288
|
+
}
|
|
289
|
+
|
|
251
290
|
/**
|
|
252
291
|
* Hydrate the items of a reactive array against the SSR nodes inside its marker
|
|
253
292
|
* range. Each item is hydrated against its own slice of the (marker-collapsed)
|
|
254
293
|
* range, IN ORDER, so every item's inner marker pairs are consumed in document
|
|
255
|
-
* order and the global cursor stays aligned for slots AFTER the list.
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
294
|
+
* order and the global cursor stays aligned for slots AFTER the list.
|
|
295
|
+
*
|
|
296
|
+
* Item templates used to need a shape — single root, no surrounding whitespace,
|
|
297
|
+
* no bare adjacent scalars — because the slice was a straight node count and
|
|
298
|
+
* the browser merges adjacent text nodes. A prettier-formatted item template
|
|
299
|
+
* was enough to break it, silently, from the second item onwards. The boundary
|
|
300
|
+
* is now accounted for (see `edgeNodeTypes`), so any item shape hydrates.
|
|
260
301
|
*/
|
|
261
302
|
function hydrateArrayItems(
|
|
262
303
|
items: unknown[],
|
|
@@ -267,8 +308,18 @@ function hydrateArrayItems(
|
|
|
267
308
|
): void {
|
|
268
309
|
const nodes = collapseMarkerRanges(rangeNodes);
|
|
269
310
|
let offset = 0;
|
|
311
|
+
/** The node type the previous item ended on, for the merge check below. */
|
|
312
|
+
let previousLast: number | null = null;
|
|
270
313
|
for (const item of items) {
|
|
271
314
|
const count = liveNodeCount(item);
|
|
315
|
+
const edges = edgeNodeTypes(item);
|
|
316
|
+
// Text-node merge at the item boundary: the previous item's trailing
|
|
317
|
+
// text and this one's leading text are ONE node in the live DOM, so this
|
|
318
|
+
// item starts where the previous one appeared to end. Without this the
|
|
319
|
+
// slice slides by one per boundary and every item after the first
|
|
320
|
+
// resolves its slot paths against the wrong nodes — reported as
|
|
321
|
+
// "slot 0 (attr) path 1.0 not found", once per slot per item.
|
|
322
|
+
if (previousLast === 3 && edges?.first === 3) offset -= 1;
|
|
272
323
|
if (isTemplateResult(item)) {
|
|
273
324
|
hydrateTemplateResult(
|
|
274
325
|
item,
|
|
@@ -287,6 +338,9 @@ function hydrateArrayItems(
|
|
|
287
338
|
);
|
|
288
339
|
}
|
|
289
340
|
offset += count;
|
|
341
|
+
// An item that contributes nothing (null/false) leaves the boundary
|
|
342
|
+
// where the last CONTRIBUTING item put it.
|
|
343
|
+
if (edges) previousLast = edges.last;
|
|
290
344
|
}
|
|
291
345
|
}
|
|
292
346
|
|
|
@@ -355,13 +409,6 @@ function hydrateTemplateResult(
|
|
|
355
409
|
// Hydration walks via the SAME path resolver as render, but against
|
|
356
410
|
// a synthetic root that mimics the parsed template's child list.
|
|
357
411
|
const tpl = getTemplate(result.strings);
|
|
358
|
-
// The live container's children should structurally match the
|
|
359
|
-
// template's content children. Wrap them in a transient DocumentFragment
|
|
360
|
-
// for path resolution — DocumentFragment.childNodes is the same view
|
|
361
|
-
// we walked during parse.
|
|
362
|
-
const syntheticRoot = {
|
|
363
|
-
childNodes: liveNodes,
|
|
364
|
-
} as unknown as ParentNode;
|
|
365
412
|
|
|
366
413
|
// An attribute interpolating several slots — `class="static ${a} ${b}"` — is
|
|
367
414
|
// ONE attribute value built from all of them plus the static segments in
|
|
@@ -371,7 +418,7 @@ function hydrateTemplateResult(
|
|
|
371
418
|
|
|
372
419
|
for (let i = 0; i < tpl.slots.length; i++) {
|
|
373
420
|
const slot = tpl.slots[i];
|
|
374
|
-
const liveNode = resolvePathLive(
|
|
421
|
+
const liveNode = resolvePathLive(slot.path, liveNodes);
|
|
375
422
|
if (!liveNode) {
|
|
376
423
|
// Path missed in the live DOM — SSR markup diverges from the
|
|
377
424
|
// parsed template's shape. Surfacing the mismatch beats silent
|
|
@@ -502,11 +549,7 @@ function collapseMarkerRanges(nodes: ChildNode[]): ChildNode[] {
|
|
|
502
549
|
return out;
|
|
503
550
|
}
|
|
504
551
|
|
|
505
|
-
function resolvePathLive(
|
|
506
|
-
_root: ParentNode,
|
|
507
|
-
path: NodePath,
|
|
508
|
-
rootNodes: ChildNode[],
|
|
509
|
-
): Node | null {
|
|
552
|
+
function resolvePathLive(path: NodePath, rootNodes: ChildNode[]): Node | null {
|
|
510
553
|
if (path.length === 0) return null;
|
|
511
554
|
// Collapse marker ranges at EVERY level so the live child list matches the
|
|
512
555
|
// parsed template's one-node-per-slot shape (see collapseMarkerRanges).
|
|
@@ -789,7 +832,10 @@ function hydratePropSlot(
|
|
|
789
832
|
cleanups: Disposer[],
|
|
790
833
|
): void {
|
|
791
834
|
function apply(v: unknown): void {
|
|
792
|
-
|
|
835
|
+
// Reflect.set rather than a cast: writing an arbitrary property onto an
|
|
836
|
+
// element is exactly what Reflect is for, and it does not require
|
|
837
|
+
// claiming the element is something it is not.
|
|
838
|
+
Reflect.set(el, slot.name, v);
|
|
793
839
|
}
|
|
794
840
|
if (isSignal(value) || typeof value === "function") {
|
|
795
841
|
const dispose = effect(() => apply((value as () => unknown)()));
|
package/src/reactive.ts
CHANGED
|
@@ -132,7 +132,10 @@ export function signal<T>(
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
// The brand is stamped through Reflect: the accessor is a function, and
|
|
136
|
+
// saying it is a branded object to write one property would be a lie the
|
|
137
|
+
// rest of the file then has to work around.
|
|
138
|
+
Reflect.set(accessor, SIGNAL_BRAND, true);
|
|
136
139
|
signalNodes.set(accessor, node);
|
|
137
140
|
return accessor as Signal<T>;
|
|
138
141
|
}
|
package/src/render.ts
CHANGED
|
@@ -405,7 +405,8 @@ function applyPropSlot(
|
|
|
405
405
|
cleanups: Disposer[],
|
|
406
406
|
): void {
|
|
407
407
|
function apply(v: unknown): void {
|
|
408
|
-
|
|
408
|
+
// See hydrate.ts: Reflect.set writes the property without a cast.
|
|
409
|
+
Reflect.set(el, slot.name, v);
|
|
409
410
|
}
|
|
410
411
|
if (isSignal(value) || typeof value === "function") {
|
|
411
412
|
const dispose = effect(() => apply((value as () => unknown)()));
|