From 1601170066d4e132703d1affae6b5b58550a921b Mon Sep 17 00:00:00 2001 From: zzal Date: Mon, 20 Jul 2026 07:16:28 -0400 Subject: [PATCH] fix: evict mountedRoots on unmount; ManualTimers tests; PersistentStore host warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three B13 hygiene items from the modularization audit: - The driver's mountedRoots map (selector→root node) had no evict path: a root unmounted and never remounted pinned its detached node forever, and the node stayed in the DOM. Swift's teardown now signals the driver via a new unmount(selector) op that detaches the root and drops the entry; HMR teardown clears the map alongside its two siblings. mountedRoots deliberately survives a bare destroyNode (replaceMount's resync ordering depends on it), so the evict is an explicit unmount signal, not a destroyNode side effect. - SwiflowTiming had no direct tests — ManualTimers was exercised only through ToastTests. New SwiflowTimingTests owns the process-global seam (.serialized) and pins schedule/advance/cancel/reset, deinit auto-cancel, due-order, in-body reschedule, and the FP-residue tolerance. - PersistentStore's host stub silently no-ops save/load; the first host save now emits a one-time DEBUG warn so a test asserting persistence against the default registry gets a signal instead of silence. Two audit items deliberately deferred: the @State/@Persisted didSet prologue extraction (assertMacroExpansion pins byte-exact output and SwiftSyntax multiline interpolation flattens the nesting — not worth churning ~10 goldens for a stable 6-line block), and the callback @MainActor unification (a source-breaking public-API change that belongs in an intentional breaking batch, invisible on the wasm target). js-driver: 105 tests. Whole-package swift test: 1916 green. EmbeddedDriver + example copies regenerated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ Package.swift | 6 + Sources/SwiflowCLI/EmbeddedDriver.swift | 22 +++- Sources/SwiflowDOM/Renderer.swift | 5 + Sources/SwiflowDOM/SwiflowDriver.swift | 10 ++ Sources/SwiflowStore/PersistentStore.swift | 15 ++- .../ManualTimersTests.swift | 108 ++++++++++++++++++ examples/AsyncFetch/swiflow-driver.js | 18 ++- examples/EdgeCases/swiflow-driver.js | 18 ++- examples/HelloWorld/swiflow-driver.js | 18 ++- examples/QueryDemo/swiflow-driver.js | 18 ++- examples/RegionDemo/swiflow-driver.js | 18 ++- examples/SwiflowUIDemo/swiflow-driver.js | 18 ++- examples/TodoCRUD/swiflow-driver.js | 18 ++- js-driver/swiflow-driver.js | 18 ++- js-driver/test/opcodes.test.js | 32 ++++++ 16 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 Tests/SwiflowTimingTests/ManualTimersTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d94ceb8..cee86ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com). ## [Unreleased] +### Fixed + +- A render root that is unmounted (`Swiflow.unmount(into:)`) and never + remounted no longer leaves its detached node pinned in the driver's + `mountedRoots` map, and is now detached from the DOM. The HMR teardown + clears the map alongside the node/listener maps it already cleared. + --- ## [0.5.4] — 2026-07-20 diff --git a/Package.swift b/Package.swift index c363c41d..d45c695a 100644 --- a/Package.swift +++ b/Package.swift @@ -294,6 +294,12 @@ let package = Package( path: "Tests/SwiflowUITests", swiftSettings: [.swiftLanguageMode(.v6)] ), + .testTarget( + name: "SwiflowTimingTests", + dependencies: ["SwiflowTiming"], + path: "Tests/SwiflowTimingTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), .testTarget( name: "SwiflowMacrosTests", dependencies: [ diff --git a/Sources/SwiflowCLI/EmbeddedDriver.swift b/Sources/SwiflowCLI/EmbeddedDriver.swift index 62f5d296..74ba29d4 100644 --- a/Sources/SwiflowCLI/EmbeddedDriver.swift +++ b/Sources/SwiflowCLI/EmbeddedDriver.swift @@ -195,7 +195,11 @@ enum EmbeddedDriver { } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -485,6 +489,17 @@ enum EmbeddedDriver { target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -759,6 +774,7 @@ enum EmbeddedDriver { // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); @@ -914,13 +930,13 @@ enum EmbeddedDriver { """# static let javascriptSourceMinified: String = #""" -(function(){"use strict";const o=new Map,m=new Map;let x=null;const h=new Map,k="./.build/plugins/PackageToJS/outputs/Package/App.wasm";function b(e){const t=document.createElement("template");if(t.innerHTML=e,t.content.childNodes.length===1)return t.content.firstChild;const n=document.createElement("span");for(;t.content.firstChild;)n.appendChild(t.content.firstChild);return n}function C(e){const t=e.target,n=t instanceof Element?t:t&&t.parentElement||null;if(!n||!n.closest||!e.currentTarget)return!1;const r=n.closest("a[href],button,input,select,textarea,summary,label,[contenteditable]:not([contenteditable='false'])");return!!(r&&r!==e.currentTarget&&e.currentTarget.contains&&e.currentTarget.contains(r))}function E(e){const t=e.target,n=t&&"value"in t?String(t.value):null,r=t&&"checked"in t?!!t.checked:null,l=typeof e.key=="string"?e.key:null;return{type:e.type,targetValue:n,targetChecked:r,isSelfTarget:t===e.currentTarget,fromInteractiveDescendant:C(e),key:l,shiftKey:!!e.shiftKey,ctrlKey:!!e.ctrlKey,altKey:!!e.altKey,metaKey:!!e.metaKey,detail:e.detail!==null&&typeof e.detail=="object"?JSON.stringify(e.detail):null}}const T=new Set(["svg","path","g","circle","ellipse","rect","line","polyline","polygon","text","tspan","defs","linearGradient","radialGradient","stop","marker","clipPath","mask","pattern","use","symbol"]);function _(e,t){for(const n of Array.from(m.keys())){const r=n.indexOf(":");if(r<0||Number(n.slice(0,r))!==e)continue;const l=n.slice(r+1),u=m.get(n);t!==void 0&&u!==void 0&&t.removeEventListener(l,u),m.delete(n)}}function S(e){switch(e.op){case"createElement":o.set(e.handle,T.has(e.tag)?document.createElementNS("http://www.w3.org/2000/svg",e.tag):document.createElement(e.tag));return;case"createText":o.set(e.handle,document.createTextNode(e.text));return;case"createRawHTML":{o.set(e.handle,b(e.html));return}case"destroyNode":{_(e.handle,o.get(e.handle)),o.delete(e.handle);return}case"animateExit":{const t=o.get(e.handle),n=o.get(e.parentHandle);if(!t)return;_(e.handle,t),t.style.animation=e.animation,setTimeout(function(){n&&t.parentNode===n?n.removeChild(t):t.parentNode&&t.parentNode.removeChild(t),o.delete(e.handle)},e.durationMs);return}case"appendChild":o.get(e.parent).appendChild(o.get(e.child));return;case"insertBefore":o.get(e.parent).insertBefore(o.get(e.child),o.get(e.beforeChild));return;case"removeChild":o.get(e.parent).removeChild(o.get(e.child));return;case"setAttribute":o.get(e.handle).setAttribute(e.name,e.value);return;case"removeAttribute":o.get(e.handle).removeAttribute(e.name);return;case"setProperty":if(e.name==="innerHTML")throw new Error("swiflow: setProperty refuses to write the innerHTML property; use VNode.rawHTML(_:) instead");o.get(e.handle)[e.name]=e.value;return;case"removeProperty":delete o.get(e.handle)[e.name];return;case"setStyle":e.name.startsWith("--")?o.get(e.handle).style.setProperty(e.name,e.value):o.get(e.handle).style[e.name]=e.value;return;case"removeStyle":o.get(e.handle).style.removeProperty(e.name);return;case"setText":{const t=o.get(e.handle);t.data!==void 0?t.data=e.text:t.textContent=e.text;return}case"setRawHTML":{const t=b(e.html),n=o.get(e.handle);n&&n.parentNode&&n.parentNode.replaceChild(t,n),o.set(e.handle,t);return}case"addHandler":{const t=e.handlerId,n=e.handle+":"+e.event,r=m.get(n);r!==void 0&&o.get(e.handle).removeEventListener(e.event,r);const l=function(u){window.__swiflowDispatch(t,E(u))};o.get(e.handle).addEventListener(e.event,l),m.set(n,l);return}case"removeHandler":{const t=e.handle+":"+e.event,n=m.get(t);n!==void 0&&(o.get(e.handle).removeEventListener(e.event,n),m.delete(t));return}case"replaceMount":{const t=document.querySelector(e.selector);if(t===null)throw new Error("swiflow-driver: replaceMount target '"+e.selector+"' not found");const n=h.get(e.selector);n!==void 0&&n.parentNode===t&&t.removeChild(n);const r=o.get(e.newHandle);h.set(e.selector,r),t.appendChild(r);return}default:console.error("swiflow-driver: unknown opcode",e.op,e);return}}async function v(e){const t=await fetch(e);if(!t.ok)throw new Error("swiflow: fetch "+e+" failed ("+t.status+")");const n=parseInt(t.headers.get("Content-Length")||"",10),r=t.body&&t.body.getReader?t.body.getReader():null;if(!r)return document.documentElement.dataset.swiflowProgress="100",t;const l=[];let u=0;const g=Number.isFinite(n)&&n>0;try{for(;;){const{done:i,value:a}=await r.read();if(i)break;if(l.push(a),u+=a.byteLength,g){const s=Math.floor(u/n*100);document.documentElement.dataset.swiflowProgress=String(Math.min(s,99))}}}catch(i){try{await r.cancel()}catch{}throw i}document.documentElement.dataset.swiflowProgress="100";const p=new Blob(l,{type:t.headers.get("Content-Type")||"application/wasm"});return new Response(p,{headers:t.headers,status:t.status})}if(window.swiflow={__stats:function(){return{nodes:o.size,listeners:m.size,mountedRoots:h.size}},applyPatches:function(e){let t=!0;for(let n=0;n0;try{for(;;){const{done:i,value:a}=await r.read();if(i)break;if(l.push(a),u+=a.byteLength,g){const s=Math.floor(u/n*100);document.documentElement.dataset.swiflowProgress=String(Math.min(s,99))}}}catch(i){try{await r.cancel()}catch{}throw i}document.documentElement.dataset.swiflowProgress="100";const p=new Blob(l,{type:t.headers.get("Content-Type")||"application/wasm"});return new Response(p,{headers:t.headers,status:t.status})}if(window.swiflow={__stats:function(){return{nodes:o.size,listeners:m.size,mountedRoots:h.size}},applyPatches:function(e){let t=!0;for(let n=0;nimport(d)),{init:w}=await c(i.jsURL);await w({module:v(i.wasmURL)});const f=(performance.now()-a).toFixed(1);console.log("[swiflow] hmr-swap took "+f+"ms")}catch(s){console.warn("[swiflow] HMR swap failed, falling back to full reload:",s),location.reload();return}finally{l=!1}if(u!==null){const s=u;u=null,p(s)}}g()}window.swiflow.__boot=async function({swiflowDev:t}){if("serviceWorker"in navigator){if(t){const n=await navigator.serviceWorker.getRegistrations();for(const r of n)if(((r.active||r.installing||r.waiting)?.scriptURL??"").endsWith("/swiflow-service-worker.js"))try{await r.unregister()}catch{}if(typeof caches<"u")try{const r=await caches.keys();await Promise.all(r.filter(l=>l.startsWith("swiflow-")).map(l=>caches.delete(l)))}catch{}return}try{await navigator.serviceWorker.register("swiflow-service-worker.js")}catch(n){console.warn("swiflow: service worker registration failed",n)}}},window.swiflow.__bootForTest=window.swiflow.__boot,window.swiflow.__test_fetchWithProgress=v,(async()=>{if(!window.__SWIFLOW_SKIP_BOOT&&(await window.swiflow.__boot({swiflowDev:!!window.SWIFLOW_DEV}),!window.swiflow.__inited)){window.swiflow.__inited=!0;try{const{init:e}=await import("./.build/plugins/PackageToJS/outputs/Package/index.js");await e({module:v(k)})}catch(e){console.error("swiflow: WASM init failed",e)}}})()})(); +`+(i&&i.stack?i.stack:String(i)));const a=document.getElementById("__swiflow-error-overlay");a&&a.remove();const s=document.createElement("div");s.id="__swiflow-error-overlay",s.style.cssText="position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.85);color:#fff;font-family:monospace;font-size:14px;padding:24px;overflow:auto;white-space:pre-wrap;word-break:break-word;";const c=document.createElement("div");c.style.cssText="font-size:18px;font-weight:bold;margin-bottom:16px;color:#ff6b6b;",c.textContent="\u26A0 Swiflow render error \u2014 WASM execution stopped";const w=document.createElement("pre");w.style.cssText="margin:0 0 16px;",w.textContent=i&&i.stack?i.stack:String(i);const f=document.createElement("div");f.style.cssText="color:#aaa;font-size:12px;margin-bottom:4px;",f.textContent="Install the Chrome C/C++ DevTools Extension to see Swift file:line in the stack above:";const d=document.createElement("a");d.href="https://goo.gle/wasm-debugging-extension",d.target="_blank",d.style.cssText="color:#4dabf7;font-size:12px;display:block;margin-bottom:16px;",d.textContent="https://goo.gle/wasm-debugging-extension";const y=document.createElement("button");y.style.cssText="padding:8px 16px;background:#444;color:#fff;border:none;cursor:pointer;font-size:14px;border-radius:4px;",y.textContent="Dismiss (app is frozen \u2014 reload to continue)",y.onclick=function(){s.remove()},s.appendChild(c),s.appendChild(w),s.appendChild(f),s.appendChild(d),s.appendChild(y),(document.body||document.documentElement).appendChild(s)},typeof window.requestAnimationFrame=="function"){var L=window.requestAnimationFrame.bind(window);window.requestAnimationFrame=function(i){return L(function(a){try{i(a)}catch(s){window.__swiflowDevError(s)}})}}let n=250;const r=5e3;let l=!1,u=null;async function p(i){if(l){u=i;return}l=!0;const a=performance.now();try{const s=window.__swiflow&&window.__swiflow.hmrSnapshot?window.__swiflow.hmrSnapshot():null;window.__swiflowPendingSnapshot=s;try{window.__swiflow&&typeof window.__swiflow.hmrTeardown=="function"&&window.__swiflow.hmrTeardown()}catch(d){console.warn("[swiflow] hmr teardown of previous module failed:",d)}if(o.clear(),m.clear(),h.clear(),x){const d=document.querySelector(x);d&&d.replaceChildren()}document.querySelectorAll('style[id^="swiflow-"]').forEach(function(d){d.remove()});const c=window.swiflow&&window.swiflow.__importOverride||(d=>import(d)),{init:w}=await c(i.jsURL);await w({module:v(i.wasmURL)});const f=(performance.now()-a).toFixed(1);console.log("[swiflow] hmr-swap took "+f+"ms")}catch(s){console.warn("[swiflow] HMR swap failed, falling back to full reload:",s),location.reload();return}finally{l=!1}if(u!==null){const s=u;u=null,p(s)}}g()}window.swiflow.__boot=async function({swiflowDev:t}){if("serviceWorker"in navigator){if(t){const n=await navigator.serviceWorker.getRegistrations();for(const r of n)if(((r.active||r.installing||r.waiting)?.scriptURL??"").endsWith("/swiflow-service-worker.js"))try{await r.unregister()}catch{}if(typeof caches<"u")try{const r=await caches.keys();await Promise.all(r.filter(l=>l.startsWith("swiflow-")).map(l=>caches.delete(l)))}catch{}return}try{await navigator.serviceWorker.register("swiflow-service-worker.js")}catch(n){console.warn("swiflow: service worker registration failed",n)}}},window.swiflow.__bootForTest=window.swiflow.__boot,window.swiflow.__test_fetchWithProgress=v,(async()=>{if(!window.__SWIFLOW_SKIP_BOOT&&(await window.swiflow.__boot({swiflowDev:!!window.SWIFLOW_DEV}),!window.swiflow.__inited)){window.swiflow.__inited=!0;try{const{init:e}=await import("./.build/plugins/PackageToJS/outputs/Package/index.js");await e({module:v(k)})}catch(e){console.error("swiflow: WASM init failed",e)}}})()})(); """# diff --git a/Sources/SwiflowDOM/Renderer.swift b/Sources/SwiflowDOM/Renderer.swift index aeb9a91b..2409705f 100644 --- a/Sources/SwiflowDOM/Renderer.swift +++ b/Sources/SwiflowDOM/Renderer.swift @@ -340,6 +340,11 @@ final class Renderer { // the test harness but never here). let patches = teardownMountTree(tree, handlers: handlers, observer: queryClient) driver.applyPatches(patches) + // Drop the driver's record of this root and detach it from the DOM. + // The teardown patches above emit the root's destroyNode (which frees + // the node/listener maps), but `mountedRoots` is keyed by selector and + // deliberately survives destroyNode, so it needs this explicit signal. + driver.unmount(selector: selector) // Stop background revalidation triggers before releasing the scheduler. backgroundRevalidation?.stop() diff --git a/Sources/SwiflowDOM/SwiflowDriver.swift b/Sources/SwiflowDOM/SwiflowDriver.swift index cacc21f5..75db7b91 100644 --- a/Sources/SwiflowDOM/SwiflowDriver.swift +++ b/Sources/SwiflowDOM/SwiflowDriver.swift @@ -35,6 +35,12 @@ protocol SwiflowDriver { /// handle (e.g. an anchor handle or a post-destroy handle). Powers /// `Ref.wrappedValue`. func nodeForHandle(_ handle: Int) -> JSObject? + + /// Detach the root mounted at `selector` from the DOM and drop the + /// driver's record of it. Called after `Swiflow.unmount(into:)` tears the + /// tree down, so an unmounted-and-never-remounted selector doesn't pin + /// its detached root. No-op for a selector that was never mounted. + func unmount(selector: String) } /// The production `SwiflowDriver`, backed by the `window.swiflow` global. @@ -89,5 +95,9 @@ struct JSDriver: SwiflowDriver { // "ref not currently bound" outcome. global.nodeForHandle!(JSValue.number(Double(handle))).object } + + func unmount(selector: String) { + _ = global.unmount!(JSValue.string(selector)) + } } #endif diff --git a/Sources/SwiflowStore/PersistentStore.swift b/Sources/SwiflowStore/PersistentStore.swift index b98bbf49..3fdf829d 100644 --- a/Sources/SwiflowStore/PersistentStore.swift +++ b/Sources/SwiflowStore/PersistentStore.swift @@ -293,14 +293,25 @@ private struct DatabaseBox: @unchecked Sendable { } #else +import Swiflow /// Host stub: the real store needs a browser. Present so app targets typecheck -/// off-WASM; `load` always yields `nil`, writes are no-ops. +/// off-WASM; `load` always yields `nil`, writes are no-ops. A host test that +/// asserts persistence against the DEFAULT registry (rather than swapping in +/// `MemoryStorage`) would otherwise pass or fail with no signal, so the first +/// host `save` emits a one-time DEBUG warn. @MainActor public final class PersistentStore { + private static var warnedOnHost = false + public init(database: String? = nil, store: String = "kv") {} public func load(_ type: T.Type, forKey key: String) async throws -> T? { nil } - public func save(_ value: T, forKey key: String) async throws {} + public func save(_ value: T, forKey key: String) async throws { + if !Self.warnedOnHost { + Self.warnedOnHost = true + swiflowWarn("PersistentStore: running on a non-WASM host — save/load are no-ops (nothing persists). Inject a MemoryStorage via _PersistedStorageRegistry to test persistence off-browser.") + } + } public func remove(forKey key: String) async throws {} } diff --git a/Tests/SwiflowTimingTests/ManualTimersTests.swift b/Tests/SwiflowTimingTests/ManualTimersTests.swift new file mode 100644 index 00000000..c3af18ad --- /dev/null +++ b/Tests/SwiflowTimingTests/ManualTimersTests.swift @@ -0,0 +1,108 @@ +// Tests/SwiflowTimingTests/ManualTimersTests.swift +// +// Direct coverage for the host `ManualTimers` queue behind `after(_:do:)`. +// Previously exercised only indirectly through ToastTests; these pin the +// schedule/advance/cancel/reset funnels themselves. Host-only by nature — +// on wasm32 `after()` is the JSOneshotClosure implementation, which has no +// host-drivable queue. +// +// ManualTimers is a process-global seam, so this is the ONE suite that owns +// it: `.serialized`, `reset()` before each test. `after()` returns a handle +// whose isolated `deinit` cancels the timer, so every live timer must be +// held for as long as it should stay armed — the tests keep handles in a +// local array (`keep`) exactly as a real owner keeps `dismissTimer`. +import Testing +@testable import SwiflowTiming + +@Suite("ManualTimers", .serialized) +@MainActor +struct ManualTimersTests { + + @Test("a scheduled timer fires once when advanced past its duration") + func firesOncePastDuration() { + ManualTimers.reset() + var fired = 0 + let keep = after(4) { fired += 1 } + #expect(ManualTimers.pendingCount == 1) + ManualTimers.advance(by: 3.9) + #expect(fired == 0) + ManualTimers.advance(by: 0.1) + #expect(fired == 1) + #expect(ManualTimers.pendingCount == 0) + ManualTimers.advance(by: 100) // already fired → no re-fire + #expect(fired == 1) + withExtendedLifetime(keep) {} + } + + @Test("repeated fractional advances don't lose time to Double residue") + func fractionalResidue() { + ManualTimers.reset() + var fired = false + let keep = after(4) { fired = true } + // 4 − 3.9 − 0.1 leaves ~8e-17; the 1e-9 tolerance must still fire it. + ManualTimers.advance(by: 3.9) + ManualTimers.advance(by: 0.1) + #expect(fired) + withExtendedLifetime(keep) {} + } + + @Test("cancel() before the due time drops the timer without firing") + func cancelBeforeDue() { + ManualTimers.reset() + var fired = false + let h = after(4) { fired = true } + h.cancel() + #expect(ManualTimers.pendingCount == 0) + ManualTimers.advance(by: 10) + #expect(!fired) + } + + @Test("dropping the handle without cancel() still cancels (isolated deinit)") + func deinitCancels() { + ManualTimers.reset() + var fired = false + do { _ = after(4) { fired = true } } // handle deallocated here + #expect(ManualTimers.pendingCount == 0) + ManualTimers.advance(by: 10) + #expect(!fired) + } + + @Test("due timers fire in ascending-remaining order") + func dueOrder() { + ManualTimers.reset() + var order: [Int] = [] + let keep = [after(3) { order.append(3) }, + after(1) { order.append(1) }, + after(2) { order.append(2) }] + ManualTimers.advance(by: 5) + #expect(order == [1, 2, 3]) + withExtendedLifetime(keep) {} + } + + @Test("a timer scheduled from within a firing body joins the queue, not the same advance") + func rescheduleWithinBody() { + ManualTimers.reset() + var inner = false + var nested: TimerHandle? + let keep = after(1) { nested = after(1) { inner = true } } + ManualTimers.advance(by: 5) // fires the outer; inner joins fresh + #expect(!inner) + #expect(ManualTimers.pendingCount == 1) + ManualTimers.advance(by: 1) + #expect(inner) + withExtendedLifetime((keep, nested)) {} + } + + @Test("reset() drops every pending timer without firing") + func resetDropsAll() { + ManualTimers.reset() + var fired = 0 + let keep = [after(1) { fired += 1 }, after(2) { fired += 1 }] + #expect(ManualTimers.pendingCount == 2) + ManualTimers.reset() + #expect(ManualTimers.pendingCount == 0) + ManualTimers.advance(by: 100) + #expect(fired == 0) + withExtendedLifetime(keep) {} + } +} diff --git a/examples/AsyncFetch/swiflow-driver.js b/examples/AsyncFetch/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/AsyncFetch/swiflow-driver.js +++ b/examples/AsyncFetch/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/EdgeCases/swiflow-driver.js b/examples/EdgeCases/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/EdgeCases/swiflow-driver.js +++ b/examples/EdgeCases/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/HelloWorld/swiflow-driver.js b/examples/HelloWorld/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/HelloWorld/swiflow-driver.js +++ b/examples/HelloWorld/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/QueryDemo/swiflow-driver.js b/examples/QueryDemo/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/QueryDemo/swiflow-driver.js +++ b/examples/QueryDemo/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/RegionDemo/swiflow-driver.js b/examples/RegionDemo/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/RegionDemo/swiflow-driver.js +++ b/examples/RegionDemo/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/SwiflowUIDemo/swiflow-driver.js b/examples/SwiflowUIDemo/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/SwiflowUIDemo/swiflow-driver.js +++ b/examples/SwiflowUIDemo/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/examples/TodoCRUD/swiflow-driver.js b/examples/TodoCRUD/swiflow-driver.js index 390f8535..63c35524 100644 --- a/examples/TodoCRUD/swiflow-driver.js +++ b/examples/TodoCRUD/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/js-driver/swiflow-driver.js b/js-driver/swiflow-driver.js index 390f8535..63c35524 100644 --- a/js-driver/swiflow-driver.js +++ b/js-driver/swiflow-driver.js @@ -185,7 +185,11 @@ } case "destroyNode": { // Symmetric with removeHandler: detach every tracked listener for - // this handle from the DOM node AND drop the map entries. + // this handle from the DOM node AND drop the map entries. NOTE: we + // deliberately do NOT touch `mountedRoots` here — it holds the root + // node by ref precisely so a `replaceMount` can still detach the old + // root after a preceding `destroyNode` (the resync-batch ordering). + // A true unmount drops the entry through `unmount(selector)`. detachListeners(p.handle, nodes.get(p.handle)); nodes.delete(p.handle); return; @@ -475,6 +479,17 @@ target.appendChild(nodes.get(rootHandle)); }, + /** Called by Swift's `Swiflow.unmount(into:)` after it has torn down the + * tree (which emits the root's `destroyNode`). Detaches the root node + * from the DOM and drops its `mountedRoots` entry, so an unmounted-and- + * never-remounted selector doesn't pin its detached root. No-op for a + * selector that was never mounted. */ + unmount: function (selector) { + const node = mountedRoots.get(selector); + if (node && node.parentNode) node.parentNode.removeChild(node); + mountedRoots.delete(selector); + }, + /** * Resolve a Swiflow handle to the live DOM node. * @@ -749,6 +764,7 @@ // site). nodes.clear(); listeners.clear(); + mountedRoots.clear(); if (mountSelector) { const t = document.querySelector(mountSelector); if (t) t.replaceChildren(); diff --git a/js-driver/test/opcodes.test.js b/js-driver/test/opcodes.test.js index 0671d8ba..10086e1c 100644 --- a/js-driver/test/opcodes.test.js +++ b/js-driver/test/opcodes.test.js @@ -665,4 +665,36 @@ describe("driver event serialization and application order", () => { assert.equal(after.nodes, 1); assert.equal(after.listeners, 0, "the exit-animated node's listener entry is evicted"); }); + + test("unmount(selector) detaches the root and drops its mountedRoots entry", () => { + const { swiflow, document } = setupDriver(); + swiflow.applyPatches([{ op: "createElement", handle: 1, tag: "div" }]); + swiflow.mount(1, "#app"); + assert.equal(swiflow.__stats().mountedRoots, 1); + assert.equal(document.querySelector("#app").children.length, 1); + // Swift's teardown emits destroyNode (frees node/listener maps) and then + // calls unmount(selector) — which is what drops the selector→node entry + // and detaches the root from the DOM. + swiflow.applyPatches([{ op: "destroyNode", handle: 1 }]); + swiflow.unmount("#app"); + assert.equal(swiflow.__stats().mountedRoots, 0); + assert.equal(document.querySelector("#app").children.length, 0); + swiflow.unmount("#app"); // idempotent + swiflow.unmount("#never"); // no-op for an unmounted selector + }); + + test("destroyNode alone leaves mountedRoots intact (replaceMount depends on it)", () => { + const { swiflow, document } = setupDriver(); + swiflow.applyPatches([{ op: "createElement", handle: 1, tag: "div" }]); + swiflow.mount(1, "#app"); + // The resync-batch ordering emits the old root's destroyNode BEFORE + // replaceMount, so the entry must survive destroyNode. + swiflow.applyPatches([ + { op: "destroyNode", handle: 1 }, + { op: "createElement", handle: 2, tag: "section" }, + { op: "replaceMount", selector: "#app", newHandle: 2 }, + ]); + assert.equal(swiflow.__stats().mountedRoots, 1); + assert.equal(document.querySelector("#app").firstElementChild.tagName, "SECTION"); + }); });