Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
22 changes: 19 additions & 3 deletions Sources/SwiflowCLI/EmbeddedDriver.swift

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Sources/SwiflowDOM/Renderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiflowDOM/SwiflowDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ protocol SwiflowDriver {
/// handle (e.g. an anchor handle or a post-destroy handle). Powers
/// `Ref<Element>.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.
Expand Down Expand Up @@ -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
15 changes: 13 additions & 2 deletions Sources/SwiflowStore/PersistentStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable>(_ type: T.Type, forKey key: String) async throws -> T? { nil }
public func save<T: Encodable>(_ value: T, forKey key: String) async throws {}
public func save<T: Encodable>(_ 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 {}
}

Expand Down
108 changes: 108 additions & 0 deletions Tests/SwiflowTimingTests/ManualTimersTests.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
}
18 changes: 17 additions & 1 deletion examples/AsyncFetch/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -749,6 +764,7 @@
// site).
nodes.clear();
listeners.clear();
mountedRoots.clear();
if (mountSelector) {
const t = document.querySelector(mountSelector);
if (t) t.replaceChildren();
Expand Down
18 changes: 17 additions & 1 deletion examples/EdgeCases/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -749,6 +764,7 @@
// site).
nodes.clear();
listeners.clear();
mountedRoots.clear();
if (mountSelector) {
const t = document.querySelector(mountSelector);
if (t) t.replaceChildren();
Expand Down
18 changes: 17 additions & 1 deletion examples/HelloWorld/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -749,6 +764,7 @@
// site).
nodes.clear();
listeners.clear();
mountedRoots.clear();
if (mountSelector) {
const t = document.querySelector(mountSelector);
if (t) t.replaceChildren();
Expand Down
18 changes: 17 additions & 1 deletion examples/QueryDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -749,6 +764,7 @@
// site).
nodes.clear();
listeners.clear();
mountedRoots.clear();
if (mountSelector) {
const t = document.querySelector(mountSelector);
if (t) t.replaceChildren();
Expand Down
18 changes: 17 additions & 1 deletion examples/RegionDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -749,6 +764,7 @@
// site).
nodes.clear();
listeners.clear();
mountedRoots.clear();
if (mountSelector) {
const t = document.querySelector(mountSelector);
if (t) t.replaceChildren();
Expand Down
Loading
Loading