From 52407fd5e9bf823d26c38e4f2f1b73dcad6272f9 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Wed, 5 Nov 2025 15:46:50 +0100 Subject: [PATCH 01/13] assert --- packages/reflow/src/html.js | 439 ++++++++++++++++++------------------ 1 file changed, 218 insertions(+), 221 deletions(-) diff --git a/packages/reflow/src/html.js b/packages/reflow/src/html.js index 36df42d..d5c3ca4 100644 --- a/packages/reflow/src/html.js +++ b/packages/reflow/src/html.js @@ -224,6 +224,7 @@ class Template { /** * @param {Sink[]} sinks + * @return {DocumentFragment} */ hydrate(sinks) { const clone = document.importNode(this.fragment, true); @@ -240,298 +241,294 @@ 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; + + 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)); + assertExists(index, "Couldn't find attr sink data"); - const element = currentElement; - element.removeAttribute(ATTR_MARKER); + const attr = sinks[index]; + assert(isAttrSink(attr)); - for (const [key, value] of Object.entries(attr)) { - if (booleanAttributes.includes(key)) { - if (value) { - element.setAttribute(key, ""); - } else { - element.removeAttribute(key); - } + 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)); } + } - listen(attr, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); - - switch (e.type) { - case "create": - case "update": { - const value = e.newValue; - if (booleanAttributes.includes(key)) { - if (value) { - element.setAttribute(key, ""); - } else { - element.removeAttribute(key); - } - if (isNonReflectedAttribute(element, key)) { - // @ts-ignore element has property [key] - element[key] = Boolean(value); - } + listen(attr, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); + + switch (e.type) { + case "create": + case "update": { + const value = e.newValue; + if (booleanAttributes.includes(key)) { + if (value) { + element.setAttribute(key, ""); } else { - element.setAttribute(key, String(value)); + element.removeAttribute(key); + } + if (isNonReflectedAttribute(element, key)) { + // @ts-ignore element has property [key] + element[key] = Boolean(value); + } + } else { + element.setAttribute(key, String(value)); - if (isNonReflectedAttribute(element, key)) { - // @ts-ignore element has property [key] - element[key] = value; - } + if (isNonReflectedAttribute(element, key)) { + // @ts-ignore element has property [key] + element[key] = value; } - break; } - case "delete": - element.removeAttribute(key); - break; + break; } - }); - } + case "delete": + 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); } + } - listen(classList, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + listen(classList, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); - const classes = key.split(" "); + const classes = key.split(" "); - switch (e.type) { - case "create": - case "update": { - if (e.newValue) { - element.classList.add(...classes); - } else { - element.classList.remove(...classes); - } - break; - } - case "delete": + switch (e.type) { + case "create": + case "update": { + if (e.newValue) { + element.classList.add(...classes); + } else { element.classList.remove(...classes); - break; + } + break; } - }); - } + case "delete": + 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); + }; - listen(listeners, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + /** + * @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); + }; - switch (e.type) { - case "create": { - const newValue = e.newValue; - addListener(key, newValue); - break; - } - case "update": { - const oldValue = e.oldValue; - const newValue = e.newValue; + for (const [key, val] of Object.entries(listeners)) { + addListener(key, /** @type {ListenerParams} */ (val)); + } - removeListener(key, oldValue); - addListener(key, newValue); - break; - } - case "delete": { - const oldValue = e.oldValue; - removeListener(key, oldValue); - break; - } + listen(listeners, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); + + switch (e.type) { + case "create": { + const newValue = e.newValue; + addListener(key, newValue); + break; } - }); - } + case "update": { + const oldValue = e.oldValue; + const newValue = e.newValue; + + removeListener(key, oldValue); + addListener(key, newValue); + break; + } + case "delete": { + const oldValue = e.oldValue; + removeListener(key, oldValue); + 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); - listen(props, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); - assert(key in element); - - switch (e.type) { - case "create": - case "update": { - // @ts-ignore key in element - element[key] = e.newValue; - break; - } - case "delete": - // @ts-ignore key in element - element[key] = null; - break; - } - }); + for (const [key, value] of Object.entries(props)) { + // @ts-ignore key in element + element[key] = value; } + + listen(props, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); + assert(key in element); + + switch (e.type) { + case "create": + case "update": { + // @ts-ignore key in element + element[key] = e.newValue; + break; + } + case "delete": + // @ts-ignore key in element + 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)); + } - listen(style, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || (typeof e.path !== "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + listen(style, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || (typeof e.path !== "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); - switch (e.type) { - case "create": - case "update": { - element.style.setProperty(key, e.newValue); - break; - } - case "delete": - element.style.removeProperty(key); - break; + switch (e.type) { + case "create": + case "update": { + element.style.setProperty(key, e.newValue); + break; } - }); - } + case "delete": + element.style.removeProperty(key); + break; + } + }); } } @@ -725,7 +722,7 @@ class Boundary { const mapper = data.mapper; /** - * @type {{ index: { value: number };data: any;boundary: Boundary;}[]} + * @type {{ index: ReactiveLeaf; data: any; boundary: Boundary; }[]} */ const boundaries = []; /** @@ -734,7 +731,7 @@ class Boundary { let labels = []; /** - * @typedef {{ start: number;deleteCount: number;values: any[]; }} SpliceOptions + * @typedef {{ start: number; deleteCount: number; values: any[]; }} SpliceOptions */ /** @@ -757,7 +754,7 @@ class Boundary { ) boundary.remove(); /** - * @type {{ index: { value: number };data: any;boundary: Boundary;}[]} + * @type {{ index: ReactiveLeaf; data: any; boundary: Boundary; }[]} */ const newBoundaries = []; @@ -796,7 +793,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 */ /** From 382925e179112702a6cd7b540446e686af237a7e Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Wed, 5 Nov 2025 15:47:09 +0100 Subject: [PATCH 02/13] use jsdelivr example --- packages/functorial/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e5dcd233d54df730ee4a6f257f683d741e039d8e Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:06:07 +0100 Subject: [PATCH 03/13] point to js files --- index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 75dfd82..8ea4fb1 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,8 @@ From fcfeec44f1d546501112966107778351b6c71637 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:08:10 +0100 Subject: [PATCH 04/13] feat(functorial): make listen disposable --- packages/functorial/src/reactive.d.ts | 4 ++-- packages/functorial/src/reactive.js | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) 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); } From f5d9036c9718a34158d5a96694954fc44c139dc4 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:10:28 +0100 Subject: [PATCH 05/13] do not remap specifiers --- scripts/build.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 49b3c5d..f00e137 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -30,14 +30,6 @@ export const buildPath = (path: string) => { content = stripTypes(content, { pathRewriting: true, removeComments: true, - remapSpecifiers: { - filePath: path, - imports: { - "@f-stack/reflow/reactivity": "./packages/reflow/src/reactivity.js", - "@f-stack/reflow": "./packages/reflow/src/mod.js", - "@f-stack/functorial": "./packages/functorial/src/reactive.js", - }, - }, }); break; From 5789f445b470a5ed05c86e935112a24827102886 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:31:13 +0100 Subject: [PATCH 06/13] implement template sink and component --- packages/reflow/src/html.d.ts | 49 ++- packages/reflow/src/html.js | 638 +++++++++++++++++++--------------- 2 files changed, 409 insertions(+), 278 deletions(-) diff --git a/packages/reflow/src/html.d.ts b/packages/reflow/src/html.d.ts index 4b64e16..04dc897 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,25 @@ 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 { + 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 +513,23 @@ 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. + * + * This `listen` function is cleaned up automatically when the scope is disposed of. + */ +export interface EffectScope extends Disposable { + disposer: DisposableStack; + 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 d5c3ca4..3e45039 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,11 +224,12 @@ class Template { /** * @param {Sink[]} sinks - * @return {DocumentFragment} + * @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} @@ -258,6 +259,7 @@ class Template { boundary.render(); walker.currentNode = end; + disposer.use(boundary); continue; } @@ -298,40 +300,42 @@ class Template { } } - listen(attr, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); - - switch (e.type) { - case "create": - case "update": { - const value = e.newValue; - if (booleanAttributes.includes(key)) { - if (value) { - element.setAttribute(key, ""); + disposer.use( + listen(attr, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); + + switch (e.type) { + case "create": + case "update": { + const value = e.newValue; + if (booleanAttributes.includes(key)) { + if (value) { + element.setAttribute(key, ""); + } else { + element.removeAttribute(key); + } + if (isNonReflectedAttribute(element, key)) { + // @ts-ignore element has property [key] + element[key] = Boolean(value); + } } else { - element.removeAttribute(key); - } - if (isNonReflectedAttribute(element, key)) { - // @ts-ignore element has property [key] - element[key] = Boolean(value); - } - } else { - element.setAttribute(key, String(value)); + element.setAttribute(key, String(value)); - if (isNonReflectedAttribute(element, key)) { - // @ts-ignore element has property [key] - element[key] = value; + if (isNonReflectedAttribute(element, key)) { + // @ts-ignore element has property [key] + element[key] = value; + } } + break; } - break; + case "delete": + element.removeAttribute(key); + break; } - case "delete": - element.removeAttribute(key); - break; - } - }); + }), + ); } // ClassList @@ -356,28 +360,30 @@ class Template { } } - listen(classList, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + disposer.use( + listen(classList, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); - const classes = key.split(" "); + const classes = key.split(" "); - switch (e.type) { - case "create": - case "update": { - if (e.newValue) { - element.classList.add(...classes); - } else { - element.classList.remove(...classes); + switch (e.type) { + case "create": + case "update": { + if (e.newValue) { + element.classList.add(...classes); + } else { + element.classList.remove(...classes); + } + break; } - break; + case "delete": + element.classList.remove(...classes); + break; } - case "delete": - element.classList.remove(...classes); - break; - } - }); + }), + ); } // On @@ -425,32 +431,34 @@ class Template { addListener(key, /** @type {ListenerParams} */ (val)); } - listen(listeners, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + disposer.use( + listen(listeners, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); - switch (e.type) { - case "create": { - const newValue = e.newValue; - addListener(key, newValue); - break; - } - case "update": { - const oldValue = e.oldValue; - const newValue = e.newValue; + switch (e.type) { + case "create": { + const newValue = e.newValue; + addListener(key, newValue); + break; + } + case "update": { + const oldValue = e.oldValue; + const newValue = e.newValue; - removeListener(key, oldValue); - addListener(key, newValue); - break; - } - case "delete": { - const oldValue = e.oldValue; - removeListener(key, oldValue); - break; + removeListener(key, oldValue); + addListener(key, newValue); + break; + } + case "delete": { + const oldValue = e.oldValue; + removeListener(key, oldValue); + break; + } } - } - }); + }), + ); } // Prop @@ -470,25 +478,27 @@ class Template { element[key] = value; } - listen(props, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || !(typeof e.path === "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); - assert(key in element); - - switch (e.type) { - case "create": - case "update": { - // @ts-ignore key in element - element[key] = e.newValue; - break; + disposer.use( + listen(props, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || !(typeof e.path === "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); + assert(key in element); + + switch (e.type) { + case "create": + case "update": { + // @ts-ignore key in element + element[key] = e.newValue; + break; + } + case "delete": + // @ts-ignore key in element + element[key] = null; + break; } - case "delete": - // @ts-ignore key in element - element[key] = null; - break; - } - }); + }), + ); } // Style @@ -513,35 +523,47 @@ class Template { currentElement.style.setProperty(key, String(value)); } - listen(style, (/** @type {ReactiveEvent} */ e) => { - if (e.type === "relabel" || (typeof e.path !== "string")) return; - const key = e.path.split(".")[1]; - assertExists(key); + disposer.use( + listen(style, (/** @type {ReactiveEvent} */ e) => { + if (e.type === "relabel" || (typeof e.path !== "string")) return; + const key = e.path.split(".")[1]; + assertExists(key); - switch (e.type) { - case "create": - case "update": { - element.style.setProperty(key, e.newValue); - break; + switch (e.type) { + case "create": + case "update": { + element.style.setProperty(key, e.newValue); + break; + } + case "delete": + element.style.removeProperty(key); + break; } - case "delete": - 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, + }; } } @@ -585,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(""); + + disposer = new DisposableStack(); + + cleanup() { + this.disposer.dispose(); + this.disposer = new DisposableStack(); + } + + [Symbol.dispose]() { + this.disposer.dispose(); + } - /** @type {Range} */ - range; + 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} @@ -609,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(""); } /** @@ -663,6 +692,7 @@ class Boundary { this.range.setStartBefore(this.#start); this.range.setEndAfter(this.#end); this.range.deleteContents(); + this.disposer.dispose(); } /** @@ -710,12 +740,13 @@ 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; @@ -1075,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), - ); - 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], - ), + 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; + 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) { @@ -1217,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; } /** @@ -1509,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) { @@ -1662,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"); @@ -1726,3 +1787,34 @@ 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, + 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](); + }, + }; + }; +} From 09139b5cd75aa889886a7f96cd91c4a4c55e17f2 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:35:16 +0100 Subject: [PATCH 07/13] update cleanup --- .../pages/reactivity/2-cleanup/Cleanup.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/playground/pages/reactivity/2-cleanup/Cleanup.ts b/playground/pages/reactivity/2-cleanup/Cleanup.ts index b6b91d5..e7c361b 100644 --- a/playground/pages/reactivity/2-cleanup/Cleanup.ts +++ b/playground/pages/reactivity/2-cleanup/Cleanup.ts @@ -1,23 +1,25 @@ -import { html } from "@f-stack/reflow"; -import { on } from "@f-stack/reflow"; -import { derived, listen, reactive } from "@f-stack/functorial"; +import { derived, reactive } from "@f-stack/functorial"; +import { component, html, on } from "@f-stack/reflow"; -export const Cleanup = () => { +export const Cleanup = component(function () { const state = reactive({ interval: 1000, elapsed: 0, }); - let id = setInterval(() => { - state.elapsed += 1; - }, state.interval); + const setup = () => + setInterval(() => { + state.elapsed += 1; + console.log(state.elapsed); + }, state.interval); - listen(state, (e) => { + let id = setup(); + this.disposer.defer(() => clearInterval(id)); + + this.listen(state, (e) => { if (e.type === "update" && e.path === ".interval") { clearInterval(id); - id = setInterval(() => { - state.elapsed += 1; - }, state.interval); + id = setup(); } }); @@ -26,4 +28,4 @@ export const Cleanup = () => {

elapsed: ${derived(() => state.elapsed)}

`; -}; +}); From 07d67ad534bf85e920dae3e17eaeafb255ad720a Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:40:09 +0100 Subject: [PATCH 08/13] add docs --- packages/reflow/src/html.d.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/reflow/src/html.d.ts b/packages/reflow/src/html.d.ts index 04dc897..6a3f1c4 100644 --- a/packages/reflow/src/html.d.ts +++ b/packages/reflow/src/html.d.ts @@ -411,6 +411,9 @@ export function isStyleSink(value: unknown): value is StyleSink; * @see {@linkcode TemplateTag} */ export interface TemplateSink extends Disposable { + /** + * The `DocumentFragment` template to mount into the DOM + */ fragment: DocumentFragment; } @@ -517,11 +520,15 @@ export type DerivedSink = Primitive | ReactiveLeaf | TemplateSink; /** * An `EffectScope` is a disposable scope used in components that provides a local {@linkcode listen} function. - * - * This `listen` function is cleaned up automatically when the scope is disposed of. */ 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; } From 7a5374a1f2c1c8ee8930b5dd1733b3a7dd747080 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:41:54 +0100 Subject: [PATCH 09/13] update nested listeners --- packages/functorial/src/reactive.test.ts | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) 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); + }), + ); } } }); From dc9b75ea745290e31d740a2a4a4423e5b2fa9ad5 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:42:23 +0100 Subject: [PATCH 10/13] update mounting --- packages/reflow/tests/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) => ` From 170832ced8c4935003b4e963f7da3436f28c16ae Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:43:01 +0100 Subject: [PATCH 11/13] update render mounting logic --- playground/render.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/render.ts b/playground/render.ts index acf9df8..c5cf654 100644 --- a/playground/render.ts +++ b/playground/render.ts @@ -1,3 +1,3 @@ import { Counter } from "./pages/reactivity/1-state-and-derived/Counter.ts"; -document.body.replaceChildren(Counter()); +document.body.replaceChildren(Counter().fragment); From 10fefbdf2501e9a9867ed3c902bb365aa134a304 Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:56:33 +0100 Subject: [PATCH 12/13] add listen type --- packages/reflow/src/html.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/reflow/src/html.js b/packages/reflow/src/html.js index 3e45039..8492abb 100644 --- a/packages/reflow/src/html.js +++ b/packages/reflow/src/html.js @@ -1802,6 +1802,9 @@ export function component(callback) { /** @type {EffectScope} */ const scope = { disposer, + /** + * @type {typeof listen} + */ listen: (node, callback) => disposer.use(listen(node, callback)), [Symbol.dispose]() { disposer.dispose(); From b19d172c338fbcea37b493b4105cd00a69bb617d Mon Sep 17 00:00:00 2001 From: fcrozatier Date: Thu, 6 Nov 2025 11:59:19 +0100 Subject: [PATCH 13/13] ignore type --- packages/reflow/src/html.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/reflow/src/html.js b/packages/reflow/src/html.js index 8492abb..1e8d991 100644 --- a/packages/reflow/src/html.js +++ b/packages/reflow/src/html.js @@ -1802,9 +1802,8 @@ export function component(callback) { /** @type {EffectScope} */ const scope = { disposer, - /** - * @type {typeof listen} - */ + // wtf + // @ts-ignore listen: (node, callback) => disposer.use(listen(node, callback)), [Symbol.dispose]() { disposer.dispose();