diff --git a/index.html b/index.html
index 75dfd82..8ea4fb1 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,8 @@
diff --git a/packages/functorial/README.md b/packages/functorial/README.md
index 9814d67..a43fce5 100644
--- a/packages/functorial/README.md
+++ b/packages/functorial/README.md
@@ -64,7 +64,7 @@ The library's single `.js` file can be load directly from a CDN
diff --git a/packages/functorial/src/reactive.d.ts b/packages/functorial/src/reactive.d.ts
index f486e43..c9cb7be 100644
--- a/packages/functorial/src/reactive.d.ts
+++ b/packages/functorial/src/reactive.d.ts
@@ -138,9 +138,9 @@ export function isPrimitive(value: unknown): value is Primitive;
* @template T
* @param {T} node
* @param {ReactiveEventCallback} callback
- * @return {() => void} A cleanup function to remove the listener
+ * @return {Disposable} A disposable object to cleanup the listener
*/
-export function listen(node: T, callback: ReactiveEventCallback): () => void;
+export function listen(node: T, callback: ReactiveEventCallback): Disposable;
/**
* Creates a derived {@linkcode reactive} with a `value` getter
diff --git a/packages/functorial/src/reactive.js b/packages/functorial/src/reactive.js
index db875c3..6b24c7a 100644
--- a/packages/functorial/src/reactive.js
+++ b/packages/functorial/src/reactive.js
@@ -462,10 +462,15 @@ export function reactive(object) {
/**
* @param {ReactiveEventCallback} callback
+ * @return {Disposable}
*/
function addListener(callback) {
callbacks.add(callback);
- return () => callbacks.delete(callback);
+ return {
+ [Symbol.dispose]() {
+ callbacks.delete(callback);
+ },
+ };
}
/**
@@ -939,8 +944,6 @@ export function isPrimitive(value) {
["string", "number", "boolean", "undefined"].includes(typeof value);
}
-function noop() {}
-
/**
* Listens to a {@linkcode reactive} graph and runs the provided callback whenever a change or call is detected
*
@@ -949,11 +952,11 @@ function noop() {}
* @template T
* @param {T} node
* @param {ReactiveEventCallback} callback
- * @return {() => void} A cleanup function to remove the listener
+ * @return {Disposable} A cleanup function to remove the listener
*/
export function listen(node, callback) {
// doing the sanity check here to avoid spreading these checks all over the codebase
- if (!isReactive(node)) return noop;
+ if (!isReactive(node)) return { [Symbol.dispose]() {} };
return getOwn(node, ns.ADD_LISTENER)(callback);
}
diff --git a/packages/functorial/src/reactive.test.ts b/packages/functorial/src/reactive.test.ts
index 2b09054..a2e6e05 100644
--- a/packages/functorial/src/reactive.test.ts
+++ b/packages/functorial/src/reactive.test.ts
@@ -459,23 +459,28 @@ Deno.test("nested listeners", () => {
const MSG2 = "even";
const MSG3 = "odd";
- let cleanup: (() => void) | undefined;
+ let disposer: DisposableStack | undefined;
listen(r, (e) => {
- cleanup?.();
+ disposer?.dispose();
events.push({ ...e, info: "root listener" });
if (e.type === "update") {
+ disposer = new DisposableStack();
if (e.newValue % 2 === 0) {
- cleanup = listen(r, (e1) => {
- events.push({ ...e1, info: "even listener" });
- (e.newValue % 6 === 0) ? logs.push(MSG1) : logs.push(MSG2);
- });
+ disposer.use(
+ listen(r, (e1) => {
+ events.push({ ...e1, info: "even listener" });
+ (e.newValue % 6 === 0) ? logs.push(MSG1) : logs.push(MSG2);
+ }),
+ );
} else {
- cleanup = listen(r, (e2) => {
- events.push({ ...e2, info: "odd listener" });
- logs.push(MSG3);
- });
+ disposer.use(
+ listen(r, (e2) => {
+ events.push({ ...e2, info: "odd listener" });
+ logs.push(MSG3);
+ }),
+ );
}
}
});
diff --git a/packages/reflow/src/html.d.ts b/packages/reflow/src/html.d.ts
index 4b64e16..6a3f1c4 100644
--- a/packages/reflow/src/html.d.ts
+++ b/packages/reflow/src/html.d.ts
@@ -1,4 +1,4 @@
-import type { Primitive, ReactiveLeaf } from "@f-stack/functorial";
+import type { listen, Primitive, ReactiveLeaf } from "@f-stack/functorial";
import type { DOMAttributesTagNameMap } from "./elements.d.ts";
/**
@@ -11,7 +11,7 @@ import type { DOMAttributesTagNameMap } from "./elements.d.ts";
export type TemplateTag = (
strings: TemplateStringsArray,
...sinks: Sink[]
-) => DocumentFragment;
+) => TemplateSink;
/**
* A sink is either an {@linkcode ElementSink} or a {@linkcode FragmentSink}
@@ -36,6 +36,7 @@ export type FragmentSink =
| DerivedSink
| MapSink
| ShowSink
+ | TemplateSink
| TextSink
| UnsafeSink;
@@ -179,7 +180,7 @@ export function isClassSink(value: unknown): value is ClassListSink;
*/
export type MapSink = {
values: T[];
- mapper: (value: T, index: ReactiveLeaf) => DocumentFragment;
+ mapper: (value: T, index: ReactiveLeaf) => TemplateSink;
};
/**
@@ -204,7 +205,7 @@ export type MapSink = {
*/
export function map(
values: T[],
- mapper: (value: T, index: ReactiveLeaf) => DocumentFragment,
+ mapper: (value: T, index: ReactiveLeaf) => TemplateSink,
): MapSink;
/**
@@ -402,6 +403,28 @@ export function style(styles: StyleSink): StyleSink;
*/
export function isStyleSink(value: unknown): value is StyleSink;
+// template
+
+/**
+ * A `TemplateSink` is a type of sink that's `Disposable`
+ *
+ * @see {@linkcode TemplateTag}
+ */
+export interface TemplateSink extends Disposable {
+ /**
+ * The `DocumentFragment` template to mount into the DOM
+ */
+ fragment: DocumentFragment;
+}
+
+/**
+ * Checks whether a sink is a template sink
+ *
+ * @param {unknown} value
+ * @returns {value is TemplateSink}
+ */
+export function isTemplateSink(value: unknown): value is TemplateSink;
+
// text
/**
@@ -493,4 +516,27 @@ export function isUnsafeHTML(value: unknown): value is UnsafeSink;
/**
* Return type of the {@linkcode derived} sink callback when inlined in the template
*/
-export type DerivedSink = Primitive | ReactiveLeaf | DocumentFragment;
+export type DerivedSink = Primitive | ReactiveLeaf | TemplateSink;
+
+/**
+ * An `EffectScope` is a disposable scope used in components that provides a local {@linkcode listen} function.
+ */
+export interface EffectScope extends Disposable {
+ /**
+ * A `DisposableStack` that holds the component unmount logic
+ */
+ disposer: DisposableStack;
+ /**
+ * The component local {@linkcode listen} function which is cleaned up automatically when the component is unmounted
+ */
+ listen: typeof listen;
+}
+
+/**
+ * Creates a component
+ *
+ * Provides a local {@linkcode listen} function that's automatically cleaned up when the component unmounts
+ */
+export function component(
+ callback: (this: EffectScope, ...args: T) => TemplateSink,
+): (...args: NoInfer) => TemplateSink;
diff --git a/packages/reflow/src/html.js b/packages/reflow/src/html.js
index 36df42d..1e8d991 100644
--- a/packages/reflow/src/html.js
+++ b/packages/reflow/src/html.js
@@ -9,7 +9,7 @@ import {
} from "@f-stack/functorial";
/**
- * @import { AttachSink, AttrSink, ClassListSink, MapSink, TagName, On, Prop, ShowSink,DerivedSink, StyleSink, TextSink, UnsafeSink, Sink } from "./html.d.ts"
+ * @import { AttachSink, AttrSink, ClassListSink, MapSink, TagName, On, Prop, ShowSink,DerivedSink, StyleSink, TextSink, UnsafeSink, Sink, TemplateSink, EffectScope } from "./html.d.ts"
*
* @import { ReactiveLeaf, ReactiveEvent } from "@f-stack/functorial"
*/
@@ -77,7 +77,7 @@ let fragmentSinkId = 0;
* @callback TemplateTag
* @param {TemplateStringsArray} strings
* @param {...Sink} sinks
- * @return {DocumentFragment}
+ * @return {TemplateSink}
*/
/**
@@ -224,10 +224,12 @@ class Template {
/**
* @param {Sink[]} sinks
+ * @return {TemplateSink}
*/
hydrate(sinks) {
const clone = document.importNode(this.fragment, true);
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ELEMENT);
+ const disposer = new DisposableStack();
/**
* @type {Element|null}
@@ -240,62 +242,65 @@ class Template {
if (currentElement.tagName.toLowerCase() === BOUNDARY_ELEMENT) {
const boundaryId = currentElement.getAttribute(BOUNDARY_MARKER);
- if (boundaryId !== null) {
- const index = this.fragmentSinks.get(+boundaryId);
- if (index !== undefined) {
- const sink = sinks[index];
- const start = document.createComment("");
- const end = document.createComment("");
- const boundary = new Boundary(sink);
+ assertExists(boundaryId, "Unexpected boundary without a boundary-id");
- boundary.start = start;
- boundary.end = end;
+ const index = this.fragmentSinks.get(+boundaryId);
+ assertExists(index, "Couldn't find boundary data");
- currentElement.replaceWith(start, end);
- boundary.render();
- walker.currentNode = end;
+ const sink = sinks[index];
+ const start = document.createComment("");
+ const end = document.createComment("");
+ const boundary = new Boundary(sink);
- continue;
- }
- }
+ boundary.start = start;
+ boundary.end = end;
+
+ currentElement.replaceWith(start, end);
+ boundary.render();
+ walker.currentNode = end;
+
+ disposer.use(boundary);
+ continue;
}
// Attach
const attachId = currentElement.getAttribute(ATTACH_MARKER);
if (attachId !== null) {
const index = this.elementSinks.get(+attachId);
- if (index !== undefined) {
- const attach = sinks[index];
- assert(isAttachSink(attach));
+ assertExists(index, "Couldn't find attach sink data");
- currentElement.removeAttribute(ATTACH_MARKER);
- attach(currentElement);
- }
+ const attach = sinks[index];
+ assert(isAttachSink(attach));
+
+ currentElement.removeAttribute(ATTACH_MARKER);
+ attach(currentElement);
}
// Attr
const attrId = currentElement.getAttribute(ATTR_MARKER);
if (attrId !== null) {
const index = this.elementSinks.get(+attrId);
- if (index !== undefined) {
- const attr = sinks[index];
- assert(isAttrSink(attr));
-
- const element = currentElement;
- element.removeAttribute(ATTR_MARKER);
-
- for (const [key, value] of Object.entries(attr)) {
- if (booleanAttributes.includes(key)) {
- if (value) {
- element.setAttribute(key, "");
- } else {
- element.removeAttribute(key);
- }
+ assertExists(index, "Couldn't find attr sink data");
+
+ const attr = sinks[index];
+ assert(isAttrSink(attr));
+
+ const element = currentElement;
+ element.removeAttribute(ATTR_MARKER);
+
+ for (const [key, value] of Object.entries(attr)) {
+ if (booleanAttributes.includes(key)) {
+ if (value) {
+ element.setAttribute(key, "");
} else {
- element.setAttribute(key, String(value));
+ element.removeAttribute(key);
}
+ } else {
+ element.setAttribute(key, String(value));
}
+ }
+ disposer.use(
listen(attr, (/** @type {ReactiveEvent} */ e) => {
if (e.type === "relabel" || !(typeof e.path === "string")) return;
const key = e.path.split(".")[1];
@@ -329,31 +334,33 @@ class Template {
element.removeAttribute(key);
break;
}
- });
- }
+ }),
+ );
}
// ClassList
const classlistId = currentElement.getAttribute(CLASSLIST_MARKER);
if (classlistId !== null) {
const index = this.elementSinks.get(+classlistId);
- if (index !== undefined) {
- const classList = sinks[index];
- assert(isClassSink(classList));
+ assertExists(index, "Couldn't find classList sink data");
- const element = currentElement;
- element.removeAttribute(CLASSLIST_MARKER);
+ const classList = sinks[index];
+ assert(isClassSink(classList));
- for (const [key, value] of Object.entries(classList)) {
- const classes = key.split(" ");
+ const element = currentElement;
+ element.removeAttribute(CLASSLIST_MARKER);
- if (value) {
- element.classList.add(...classes);
- } else {
- element.classList.remove(...classes);
- }
+ for (const [key, value] of Object.entries(classList)) {
+ const classes = key.split(" ");
+
+ if (value) {
+ element.classList.add(...classes);
+ } else {
+ element.classList.remove(...classes);
}
+ }
+ disposer.use(
listen(classList, (/** @type {ReactiveEvent} */ e) => {
if (e.type === "relabel" || !(typeof e.path === "string")) return;
const key = e.path.split(".")[1];
@@ -375,58 +382,56 @@ class Template {
element.classList.remove(...classes);
break;
}
- });
- }
+ }),
+ );
}
// On
const onId = currentElement.getAttribute(ON_MARKER);
if (onId !== null) {
const index = this.elementSinks.get(+onId);
- if (index !== undefined) {
- const listeners = sinks[index];
- assert(isOnSink(listeners));
+ assertExists(index, "Couldn't find on sink data");
- const element = currentElement;
- element.removeAttribute(ON_MARKER);
- const elementListeners = new WeakMap();
+ const listeners = sinks[index];
+ assert(isOnSink(listeners));
- /**
- * @typedef { EventListener | [EventListener,options?: boolean | AddEventListenerOptions]} ListenerParams
- */
+ const element = currentElement;
+ element.removeAttribute(ON_MARKER);
+ const elementListeners = new WeakMap();
- /**
- * @param {string} type
- * @param {ListenerParams} params
- */
- const addListener = (type, params) => {
- const [listener, options] = Array.isArray(params)
- ? params
- : [params];
- const ref = snapshot(listener);
- const bound = ref.bind(currentElement);
- element.addEventListener(type, bound, options);
- elementListeners.set(ref, bound);
- };
+ /**
+ * @typedef { EventListener | [EventListener,options?: boolean | AddEventListenerOptions]} ListenerParams
+ */
- /**
- * @param {string} type
- * @param {ListenerParams} params
- */
- const removeListener = (type, params) => {
- const [listener, options] = Array.isArray(params)
- ? params
- : [params];
- const ref = snapshot(listener);
- const bound = elementListeners.get(ref);
- element.removeEventListener(type, bound, options);
- elementListeners.delete(ref);
- };
-
- for (const [key, val] of Object.entries(listeners)) {
- addListener(key, /** @type {ListenerParams} */ (val));
- }
+ /**
+ * @param {string} type
+ * @param {ListenerParams} params
+ */
+ const addListener = (type, params) => {
+ const [listener, options] = Array.isArray(params) ? params : [params];
+ const ref = snapshot(listener);
+ const bound = ref.bind(currentElement);
+ element.addEventListener(type, bound, options);
+ elementListeners.set(ref, bound);
+ };
+ /**
+ * @param {string} type
+ * @param {ListenerParams} params
+ */
+ const removeListener = (type, params) => {
+ const [listener, options] = Array.isArray(params) ? params : [params];
+ const ref = snapshot(listener);
+ const bound = elementListeners.get(ref);
+ element.removeEventListener(type, bound, options);
+ elementListeners.delete(ref);
+ };
+
+ for (const [key, val] of Object.entries(listeners)) {
+ addListener(key, /** @type {ListenerParams} */ (val));
+ }
+
+ disposer.use(
listen(listeners, (/** @type {ReactiveEvent} */ e) => {
if (e.type === "relabel" || !(typeof e.path === "string")) return;
const key = e.path.split(".")[1];
@@ -452,26 +457,28 @@ class Template {
break;
}
}
- });
- }
+ }),
+ );
}
// Prop
const propId = currentElement.getAttribute(PROP_MARKER);
if (propId !== null) {
const index = this.elementSinks.get(+propId);
- if (index !== undefined) {
- const props = sinks[index];
- assert(isPropSink(props));
+ assertExists(index, "Couldn't find prop sink data");
- const element = currentElement;
- element.removeAttribute(PROP_MARKER);
+ const props = sinks[index];
+ assert(isPropSink(props));
- for (const [key, value] of Object.entries(props)) {
- // @ts-ignore key in element
- element[key] = value;
- }
+ const element = currentElement;
+ element.removeAttribute(PROP_MARKER);
+
+ for (const [key, value] of Object.entries(props)) {
+ // @ts-ignore key in element
+ element[key] = value;
+ }
+ disposer.use(
listen(props, (/** @type {ReactiveEvent} */ e) => {
if (e.type === "relabel" || !(typeof e.path === "string")) return;
const key = e.path.split(".")[1];
@@ -490,31 +497,33 @@ class Template {
element[key] = null;
break;
}
- });
- }
+ }),
+ );
}
// Style
const styleId = currentElement.getAttribute(STYLE_MARKER);
if (styleId !== null) {
const index = this.elementSinks.get(+styleId);
- if (index !== undefined) {
- const style = sinks[index];
- assert(isStyleSink(style));
- assert(
- currentElement instanceof HTMLElement ||
- currentElement instanceof SVGElement ||
- currentElement instanceof MathMLElement,
- "expected an html, svg or mathML element",
- );
-
- const element = currentElement;
- element.removeAttribute(STYLE_MARKER);
-
- for (const [key, value] of Object.entries(style)) {
- currentElement.style.setProperty(key, String(value));
- }
+ assertExists(index, "Couldn't find style sink data");
+
+ const style = sinks[index];
+ assert(isStyleSink(style));
+ assert(
+ currentElement instanceof HTMLElement ||
+ currentElement instanceof SVGElement ||
+ currentElement instanceof MathMLElement,
+ "Expected an html, svg or mathML element",
+ );
+
+ const element = currentElement;
+ element.removeAttribute(STYLE_MARKER);
+
+ for (const [key, value] of Object.entries(style)) {
+ currentElement.style.setProperty(key, String(value));
+ }
+ disposer.use(
listen(style, (/** @type {ReactiveEvent} */ e) => {
if (e.type === "relabel" || (typeof e.path !== "string")) return;
const key = e.path.split(".")[1];
@@ -530,21 +539,31 @@ class Template {
element.style.removeProperty(key);
break;
}
- });
- }
+ }),
+ );
}
}
+ let fragment = clone;
+
if (this.mode !== "html") {
const wrapper = clone.firstElementChild;
const result = document.createDocumentFragment();
- if (!wrapper) return result;
+ assertExists(wrapper, "Unexpected null wrapper");
+
// no `children` spreading to avoid array conversion from `HTMLCollection`
while (wrapper.firstChild) result.append(wrapper.firstChild);
- return result;
+ fragment = result;
}
- return clone;
+ return {
+ fragment,
+ [Symbol.dispose]() {
+ disposer.dispose();
+ },
+ // @ts-ignore
+ [TEMPLATE_SINK]: true,
+ };
}
}
@@ -588,16 +607,26 @@ function isNonReflectedAttribute(element, key) {
}
/**
- * A {@linkcode Boundary} is a live `DocumentFragment` with a start and end `Comment` nodes.
+ * A {@linkcode Boundary} is a disposable live `DocumentFragment` with a start and end `Comment` nodes.
+ *
+ * @internal
*/
class Boundary {
- /** @type {Comment} */
- #start;
- /** @type {Comment} */
- #end;
+ #start = document.createComment("");
+ #end = document.createComment("");
- /** @type {Range} */
- range;
+ disposer = new DisposableStack();
+
+ cleanup() {
+ this.disposer.dispose();
+ this.disposer = new DisposableStack();
+ }
+
+ [Symbol.dispose]() {
+ this.disposer.dispose();
+ }
+
+ range = new Range();
/**
* Holds the data the {@linkcode Boundary} manages, which can be any of the different sorts of sinks, a {@linkcode ReactiveLeaf} or a {@linkcode Primitive}
@@ -612,10 +641,7 @@ class Boundary {
* @param {Sink} data
*/
constructor(data) {
- this.range = new Range();
this.data = data;
- this.#start = document.createComment("");
- this.#end = document.createComment("");
}
/**
@@ -666,6 +692,7 @@ class Boundary {
this.range.setStartBefore(this.#start);
this.range.setEndAfter(this.#end);
this.range.deleteContents();
+ this.disposer.dispose();
}
/**
@@ -713,19 +740,20 @@ class Boundary {
render() {
const data = this.data;
- if (data instanceof DocumentFragment) {
- this.#end.before(data);
- } else if (isPrimitive(data)) {
+ if (isPrimitive(data)) {
this.#end.before(String(data ?? ""));
- } else if (isReactiveLeaf(data) && !isUnsafeHTML(data)) {
- this.renderDerivedSink(data);
+ } else if (
+ isTemplateSink(data) ||
+ (isReactiveLeaf(data) && !isUnsafeHTML(data))
+ ) {
+ this.disposer.use(this.renderDerivedSink(data));
} else if (isMapSink(data)) {
const thisEnd = this.end;
const values = data.values;
const mapper = data.mapper;
/**
- * @type {{ index: { value: number };data: any;boundary: Boundary;}[]}
+ * @type {{ index: ReactiveLeaf; data: any; boundary: Boundary; }[]}
*/
const boundaries = [];
/**
@@ -734,7 +762,7 @@ class Boundary {
let labels = [];
/**
- * @typedef {{ start: number;deleteCount: number;values: any[]; }} SpliceOptions
+ * @typedef {{ start: number; deleteCount: number; values: any[]; }} SpliceOptions
*/
/**
@@ -757,7 +785,7 @@ class Boundary {
) boundary.remove();
/**
- * @type {{ index: { value: number };data: any;boundary: Boundary;}[]}
+ * @type {{ index: ReactiveLeaf; data: any; boundary: Boundary; }[]}
*/
const newBoundaries = [];
@@ -796,7 +824,7 @@ class Boundary {
const splices = [];
/**
- * @typedef {{ type: "insert" | "delete" | "swap" } | { type: "move"; from: number; to: number }} Tag
+ * @typedef {{ type: "insert" | "delete" | "swap" } | { type: "move"; from: number; to: number }} Tag
*/
/**
@@ -1078,141 +1106,146 @@ class Boundary {
spliceBoundaries(0, 0, ...values);
// Creates a functorial relation with the original reactive array
- listen(values, (/** @type {ReactiveEvent} */ e) => {
- switch (e.type) {
- case "relabel": {
- labels = e.labels;
- return;
- }
- case "update": {
- if (typeof e.path !== "string") return;
- // Ignore derived updates of the length property
- if (e.path === ".length") return;
- const index = Number(e.path.split(".")[1]);
- const b = boundaries[index];
- assertExists(b);
-
- // updates are already handled are the reactive object level for non primitive types
- if (isPrimitive(b.data)) {
- b.data = e.newValue;
- b.boundary.data = mapper(
- e.newValue,
- reactive({ value: index }),
- );
- b.boundary.deleteContents();
- b.boundary.render();
- }
- break;
- }
- case "apply": {
- if (![".reverse", ".sort", ".splice"].includes(String(e.path))) {
- labels = [];
+ this.disposer.use(
+ listen(values, (/** @type {ReactiveEvent} */ e) => {
+ switch (e.type) {
+ case "relabel": {
+ labels = e.labels;
+ return;
}
-
- switch (e.path) {
- case ".push":
- updates.push(
- spliceBoundaries.bind(null, boundaries.length, 0, ...e.args),
+ case "update": {
+ if (typeof e.path !== "string") return;
+ // Ignore derived updates of the length property
+ if (e.path === ".length") return;
+ const index = Number(e.path.split(".")[1]);
+ const b = boundaries[index];
+ assertExists(b);
+
+ // updates are already handled are the reactive object level for non primitive types
+ if (isPrimitive(b.data)) {
+ b.data = e.newValue;
+ b.boundary.data = mapper(
+ e.newValue,
+ reactive({ value: index }),
);
- break;
- case ".unshift":
- updates.push(
- spliceBoundaries.bind(null, 0, 0, ...e.args),
- );
- break;
- case ".concat":
- updates.push(
- spliceBoundaries.bind(
- null,
- boundaries.length,
- 0,
- ...e.args[0],
- ),
- );
- break;
+ b.boundary.deleteContents();
+ b.boundary.render();
+ }
+ break;
+ }
+ case "apply": {
+ if (![".reverse", ".sort", ".splice"].includes(String(e.path))) {
+ labels = [];
+ }
- case ".pop":
- updates.push(
- spliceBoundaries.bind(null, boundaries.length - 1, 1),
- );
- break;
- case ".shift":
- updates.push(spliceBoundaries.bind(null, 0, 1));
- break;
+ switch (e.path) {
+ case ".push":
+ updates.push(
+ spliceBoundaries.bind(
+ null,
+ boundaries.length,
+ 0,
+ ...e.args,
+ ),
+ );
+ break;
+ case ".unshift":
+ updates.push(
+ spliceBoundaries.bind(null, 0, 0, ...e.args),
+ );
+ break;
+ case ".concat":
+ updates.push(
+ spliceBoundaries.bind(
+ null,
+ boundaries.length,
+ 0,
+ ...e.args[0],
+ ),
+ );
+ break;
- case ".splice": {
- const [start, deleteCount, ...values] = e.args;
- moveAndSpliceBoundaries(start, deleteCount, ...values);
- break;
- }
- case ".fill": {
- const [value, start, end] = e.args;
- for (const b of boundaries.slice(start, end)) {
- b.data = value;
+ case ".pop":
+ updates.push(
+ spliceBoundaries.bind(null, boundaries.length - 1, 1),
+ );
+ break;
+ case ".shift":
+ updates.push(spliceBoundaries.bind(null, 0, 1));
+ break;
+
+ case ".splice": {
+ const [start, deleteCount, ...values] = e.args;
+ moveAndSpliceBoundaries(start, deleteCount, ...values);
+ break;
}
- break;
- }
- case ".copyWithin": {
- const [target, start, end] = e.args;
+ case ".fill": {
+ const [value, start, end] = e.args;
+ for (const b of boundaries.slice(start, end)) {
+ b.data = value;
+ }
+ break;
+ }
+ case ".copyWithin": {
+ const [target, start, end] = e.args;
- for (let index = 0; index < end - start; index++) {
- const targetBoundary = boundaries[target + index];
- const sourceBoundary = boundaries[start + index];
+ for (let index = 0; index < end - start; index++) {
+ const targetBoundary = boundaries[target + index];
+ const sourceBoundary = boundaries[start + index];
- assertExists(targetBoundary);
- assertExists(sourceBoundary);
+ assertExists(targetBoundary);
+ assertExists(sourceBoundary);
- targetBoundary.data = sourceBoundary.data;
+ targetBoundary.data = sourceBoundary.data;
+ }
+ break;
}
- break;
- }
- case ".reverse":
- case ".sort": {
- if (labels.length > 0) {
- /**
- * @type {[number, number][]}
- */
- const moves = labels.map(
- ([a, b]) => [+a.slice(1), +b.slice(1)],
- );
-
- updates.push(moveBoundaries.bind(null, moves));
- labels = [];
+ case ".reverse":
+ case ".sort": {
+ if (labels.length > 0) {
+ /**
+ * @type {[number, number][]}
+ */
+ const moves = labels.map(
+ ([a, b]) => [+a.slice(1), +b.slice(1)],
+ );
+
+ updates.push(moveBoundaries.bind(null, moves));
+ labels = [];
+ }
+ break;
}
- break;
}
- }
- if (updates.length > 0) {
- maybeViewTransition(() => {
- for (const update of updates) {
- update();
- }
- updates.length = 0;
- });
+ if (updates.length > 0) {
+ maybeViewTransition(() => {
+ for (const update of updates) {
+ update();
+ }
+ updates.length = 0;
+ });
+ }
}
}
- }
- });
+ }),
+ );
} else if (isTextSink(data)) {
const content = data.data;
const key = data.key;
const textNode = new Text(String(content[key] ?? ""));
this.#end.before(textNode);
- listen(content, (/** @type {ReactiveEvent} */ e) => {
- if (e.type !== "update") return;
- if (e.path !== `.${key}`) return;
- textNode.data = String(e.newValue ?? "");
- });
+ this.disposer.use(
+ listen(content, (/** @type {ReactiveEvent} */ e) => {
+ if (e.type !== "update") return;
+ if (e.path !== `.${key}`) return;
+ textNode.data = String(e.newValue ?? "");
+ }),
+ );
} else if (isShowSink(data)) {
- /**
- * @type {(() => void) | undefined}
- */
- let cleanup;
-
/**
* @param {(() => DerivedSink) | undefined} currentCase
+ * @returns {DisposableStack | undefined}
*/
const setup = (currentCase) => {
if (currentCase) {
@@ -1220,70 +1253,80 @@ class Boundary {
}
};
- cleanup = setup(data.cond ? data.ifCase : data.elseCase);
+ let cleanup = setup(data.cond ? data.ifCase : data.elseCase);
- listen(data, (/** @type {ReactiveEvent} */ e) => {
- // ensure we're in the right case before cleanup
- if (e.type !== "update" || e.path !== ".cond") return;
- cleanup?.();
- this.deleteContents();
- cleanup = setup(e.newValue ? data.ifCase : data.elseCase);
- });
+ this.disposer.use(
+ listen(data, (/** @type {ReactiveEvent} */ e) => {
+ // ensure we're in the right case before cleaning up
+ if (e.type !== "update" || e.path !== ".cond") return;
+ cleanup?.[Symbol.dispose]();
+ this.deleteContents();
+ cleanup = setup(e.newValue ? data.ifCase : data.elseCase);
+ }),
+ );
} else if (isUnsafeHTML(data)) {
// unsafe sink
const template = document.createElement("template");
template.innerHTML = data.value;
this.replaceChildren(template.content);
- listen(data, (/** @type {ReactiveEvent} */ e) => {
- switch (e.type) {
- case "update":
- template.innerHTML = e.newValue;
- this.replaceChildren(template.content);
- break;
- }
- });
+ this.disposer.use(
+ listen(data, (/** @type {ReactiveEvent} */ e) => {
+ switch (e.type) {
+ case "update":
+ template.innerHTML = e.newValue;
+ this.replaceChildren(template.content);
+ break;
+ }
+ }),
+ );
} else {
throw new Error(`Unexpected sink: ${data}`);
}
}
/**
- * Interpolates {@linkcode ReactiveLeaf | ReactiveLeaves} and {@linkcode Primitive | Primitives} as safe `Text` nodes and also inserts nested `DocumentFragments` as-is
+ * Interpolates {@linkcode ReactiveLeaf | ReactiveLeaves} and {@linkcode Primitive | Primitives} as safe `Text` nodes and inserts nested {@linkcode TemplateSink}
*
* @param {DerivedSink} data
- * @returns {(() => void) | undefined}
+ * @returns {DisposableStack}
*/
renderDerivedSink(data) {
const content = isReactiveLeaf(data) ? data.value : data;
+ const disposable = new DisposableStack();
- if (content instanceof DocumentFragment) {
- this.#end.before(content);
- } else {
+ if (isPrimitive(content)) {
// a text node is a safe sink
this.#end.before(String(content ?? ""));
+ } else {
+ disposable.use(content);
+ this.#end.before(content.fragment);
}
- return listen(data, (/** @type {ReactiveEvent} */ e) => {
- if (e.type !== "update" && e.type !== "delete") return;
- if (e.path !== ".value") return;
+ disposable.use(
+ listen(data, (/** @type {ReactiveEvent} */ e) => {
+ if (e.type !== "update" && e.type !== "delete") return;
+ if (e.path !== ".value") return;
- switch (e.type) {
- case "update": {
- const newValue = snapshot(e.newValue);
- if (newValue instanceof DocumentFragment) {
- this.replaceChildren(newValue);
- } else {
- this.replaceChildren(String(e.newValue ?? ""));
+ switch (e.type) {
+ case "update": {
+ const newValue = snapshot(e.newValue);
+ if (isPrimitive(newValue)) {
+ this.replaceChildren(String(e.newValue ?? ""));
+ } else {
+ this.replaceChildren(newValue);
+ }
+ break;
}
- break;
+
+ case "delete":
+ this.deleteContents();
+ break;
}
+ }),
+ );
- case "delete":
- this.deleteContents();
- break;
- }
- });
+ return disposable;
}
/**
@@ -1512,7 +1555,7 @@ const MAP_SINK = Symbol.for("map sink");
*
* @template T
* @param {T[]} values The {@linkcode reactive} array to iterate on
- * @param {(value: T, index: ReactiveLeaf) => DocumentFragment} mapper A callback taking as input a single `value` from the array and its `index`
+ * @param {(value: T, index: ReactiveLeaf) => TemplateSink} mapper A callback taking as input a single `value` from the array and its `index`
* @returns {MapSink}
*/
export function map(values, mapper) {
@@ -1665,6 +1708,21 @@ export function isStyleSink(value) {
Object.hasOwn(value, STYLE_SINK);
}
+// template
+
+const TEMPLATE_SINK = Symbol.for("template sink");
+
+/**
+ * Checks whether a sink is an {@linkcode attach} sink
+ *
+ * @param {unknown} value
+ * @returns {value is TemplateSink}
+ */
+export function isTemplateSink(value) {
+ return value !== null && typeof value === "object" &&
+ Object.hasOwn(value, TEMPLATE_SINK);
+}
+
// text
const TEXT_SINK = Symbol.for("text sink");
@@ -1729,3 +1787,36 @@ export function isUnsafeHTML(value) {
value !== null &&
Object.hasOwn(value, UNSAFE_SINK);
}
+
+/**
+ * Creates a component
+ *
+ * @template {any[]} T
+ * @param {(this: EffectScope, ...args:T) => TemplateSink} callback
+ * @returns {(...args: T) => TemplateSink}
+ */
+export function component(callback) {
+ return (...args) => {
+ const disposer = new DisposableStack();
+
+ /** @type {EffectScope} */
+ const scope = {
+ disposer,
+ // wtf
+ // @ts-ignore
+ listen: (node, callback) => disposer.use(listen(node, callback)),
+ [Symbol.dispose]() {
+ disposer.dispose();
+ },
+ };
+
+ const templateSink = disposer.use(callback.apply(scope, args));
+
+ return {
+ fragment: templateSink.fragment,
+ [Symbol.dispose]() {
+ scope[Symbol.dispose]();
+ },
+ };
+ };
+}
diff --git a/packages/reflow/tests/server.ts b/packages/reflow/tests/server.ts
index 6b9b590..357e9f6 100644
--- a/packages/reflow/tests/server.ts
+++ b/packages/reflow/tests/server.ts
@@ -22,7 +22,7 @@ const template = (path: string) => `