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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).

## [Unreleased]

### Fixed

- Exit-animated unmounts (e.g. every Toast dismissal) no longer leak a
driver listener entry per handler: the `animateExit` opcode now runs the
same listener-eviction sweep as `destroyNode`, and detaches immediately so
clicks during the exit window can't dispatch into evicted handlers.
- The dev-mode `window.__swiflow` API no longer re-creates its four closures
on every render/unmount (each re-install pinned the prior set forever).

### Removed

- **BREAKING:** the `HTTP` static facade (`HTTP.get/post/put/patch/delete/send`)
Expand Down
50 changes: 30 additions & 20 deletions Sources/SwiflowCLI/EmbeddedDriver.swift

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions Sources/SwiflowDOM/DevAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,23 @@ enum DevAPI {

// MARK: - Install

/// Installs (or re-installs) `window.__swiflow` commands pointing at all
/// currently mounted roots. Called after every `render(into:)` and
/// `unmount(into:)` so the API always reflects the live root set.
/// Installs `window.__swiflow` commands. Idempotent: called after every
/// `render(into:)` and `unmount(into:)`, but only the first call creates
/// the closures — they read the live `renderers` set on each invocation,
/// so the API reflects the current roots without re-installation.
///
/// All four commands return JS objects keyed by selector when multiple
/// roots are mounted, and return the same structure for a single root so
/// existing usage is unchanged.
@MainActor
static func installAll() {
guard JSObject.global.SWIFLOW_DEV.boolean == true else { return }
// Install once: every closure reads the live `renderers` static at
// call time, so one install stays correct as roots mount/unmount —
// and re-creating the closures would pin the prior four in
// JavaScriptKit's static sharedClosures table forever (nil-ing a
// JSClosure field never releases it).
guard treeClosure == nil else { return }

let ns = swiflowDevNamespace()

Expand Down
46 changes: 28 additions & 18 deletions examples/AsyncFetch/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@
"stop", "marker", "clipPath", "mask", "pattern", "use", "symbol",
]);

/**
* Detach every tracked listener for `handle` from `node` and drop the
* `listeners` map entries. Shared by `destroyNode` and `animateExit` —
* both destruction paths must run the same sweep or entries leak. The
* handle prefix is parsed numerically: a startsWith match would hit
* handle 1 against keys for 10/11/etc.
*/
function detachListeners(handle, node) {
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
}

/**
* Apply a single patch. The opcode is `p.op`; field names match the
* Swift-side `PatchSerializer.encode(...)` contract.
Expand Down Expand Up @@ -165,31 +186,20 @@
case "destroyNode": {
// Symmetric with removeHandler: detach every tracked listener for
// this handle from the DOM node AND drop the map entries.
// Previously this case only deleted from the map, leaving the DOM
// bindings until GC -- mostly harmless but inconsistent with
// removeHandler and falsified the comment that claimed "Detach any
// listeners". Also: parse the handle prefix numerically (was
// startsWith-based, which would match handle 1 against keys for
// 10/11/etc. once handles cross 10).
const node = nodes.get(p.handle);
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== p.handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
detachListeners(p.handle, nodes.get(p.handle));
nodes.delete(p.handle);
return;
}
case "animateExit": {
const node = nodes.get(p.handle);
const parent = nodes.get(p.parentHandle);
if (!node) return;
// The Swift side evicts this node's handler ids when it emits the
// patch (destroyNode is suppressed for the animated root), so its
// listeners must be detached NOW — kept through the animation
// window they dispatch into evicted ids and their map entries
// outlive the node.
detachListeners(p.handle, node);
node.style.animation = p.animation;
setTimeout(function () {
if (parent && node.parentNode === parent) {
Expand Down
46 changes: 28 additions & 18 deletions examples/EdgeCases/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@
"stop", "marker", "clipPath", "mask", "pattern", "use", "symbol",
]);

/**
* Detach every tracked listener for `handle` from `node` and drop the
* `listeners` map entries. Shared by `destroyNode` and `animateExit` —
* both destruction paths must run the same sweep or entries leak. The
* handle prefix is parsed numerically: a startsWith match would hit
* handle 1 against keys for 10/11/etc.
*/
function detachListeners(handle, node) {
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
}

/**
* Apply a single patch. The opcode is `p.op`; field names match the
* Swift-side `PatchSerializer.encode(...)` contract.
Expand Down Expand Up @@ -165,31 +186,20 @@
case "destroyNode": {
// Symmetric with removeHandler: detach every tracked listener for
// this handle from the DOM node AND drop the map entries.
// Previously this case only deleted from the map, leaving the DOM
// bindings until GC -- mostly harmless but inconsistent with
// removeHandler and falsified the comment that claimed "Detach any
// listeners". Also: parse the handle prefix numerically (was
// startsWith-based, which would match handle 1 against keys for
// 10/11/etc. once handles cross 10).
const node = nodes.get(p.handle);
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== p.handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
detachListeners(p.handle, nodes.get(p.handle));
nodes.delete(p.handle);
return;
}
case "animateExit": {
const node = nodes.get(p.handle);
const parent = nodes.get(p.parentHandle);
if (!node) return;
// The Swift side evicts this node's handler ids when it emits the
// patch (destroyNode is suppressed for the animated root), so its
// listeners must be detached NOW — kept through the animation
// window they dispatch into evicted ids and their map entries
// outlive the node.
detachListeners(p.handle, node);
node.style.animation = p.animation;
setTimeout(function () {
if (parent && node.parentNode === parent) {
Expand Down
46 changes: 28 additions & 18 deletions examples/HelloWorld/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@
"stop", "marker", "clipPath", "mask", "pattern", "use", "symbol",
]);

/**
* Detach every tracked listener for `handle` from `node` and drop the
* `listeners` map entries. Shared by `destroyNode` and `animateExit` —
* both destruction paths must run the same sweep or entries leak. The
* handle prefix is parsed numerically: a startsWith match would hit
* handle 1 against keys for 10/11/etc.
*/
function detachListeners(handle, node) {
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
}

/**
* Apply a single patch. The opcode is `p.op`; field names match the
* Swift-side `PatchSerializer.encode(...)` contract.
Expand Down Expand Up @@ -165,31 +186,20 @@
case "destroyNode": {
// Symmetric with removeHandler: detach every tracked listener for
// this handle from the DOM node AND drop the map entries.
// Previously this case only deleted from the map, leaving the DOM
// bindings until GC -- mostly harmless but inconsistent with
// removeHandler and falsified the comment that claimed "Detach any
// listeners". Also: parse the handle prefix numerically (was
// startsWith-based, which would match handle 1 against keys for
// 10/11/etc. once handles cross 10).
const node = nodes.get(p.handle);
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== p.handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
detachListeners(p.handle, nodes.get(p.handle));
nodes.delete(p.handle);
return;
}
case "animateExit": {
const node = nodes.get(p.handle);
const parent = nodes.get(p.parentHandle);
if (!node) return;
// The Swift side evicts this node's handler ids when it emits the
// patch (destroyNode is suppressed for the animated root), so its
// listeners must be detached NOW — kept through the animation
// window they dispatch into evicted ids and their map entries
// outlive the node.
detachListeners(p.handle, node);
node.style.animation = p.animation;
setTimeout(function () {
if (parent && node.parentNode === parent) {
Expand Down
46 changes: 28 additions & 18 deletions examples/QueryDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@
"stop", "marker", "clipPath", "mask", "pattern", "use", "symbol",
]);

/**
* Detach every tracked listener for `handle` from `node` and drop the
* `listeners` map entries. Shared by `destroyNode` and `animateExit` —
* both destruction paths must run the same sweep or entries leak. The
* handle prefix is parsed numerically: a startsWith match would hit
* handle 1 against keys for 10/11/etc.
*/
function detachListeners(handle, node) {
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
}

/**
* Apply a single patch. The opcode is `p.op`; field names match the
* Swift-side `PatchSerializer.encode(...)` contract.
Expand Down Expand Up @@ -165,31 +186,20 @@
case "destroyNode": {
// Symmetric with removeHandler: detach every tracked listener for
// this handle from the DOM node AND drop the map entries.
// Previously this case only deleted from the map, leaving the DOM
// bindings until GC -- mostly harmless but inconsistent with
// removeHandler and falsified the comment that claimed "Detach any
// listeners". Also: parse the handle prefix numerically (was
// startsWith-based, which would match handle 1 against keys for
// 10/11/etc. once handles cross 10).
const node = nodes.get(p.handle);
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== p.handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
detachListeners(p.handle, nodes.get(p.handle));
nodes.delete(p.handle);
return;
}
case "animateExit": {
const node = nodes.get(p.handle);
const parent = nodes.get(p.parentHandle);
if (!node) return;
// The Swift side evicts this node's handler ids when it emits the
// patch (destroyNode is suppressed for the animated root), so its
// listeners must be detached NOW — kept through the animation
// window they dispatch into evicted ids and their map entries
// outlive the node.
detachListeners(p.handle, node);
node.style.animation = p.animation;
setTimeout(function () {
if (parent && node.parentNode === parent) {
Expand Down
46 changes: 28 additions & 18 deletions examples/RegionDemo/swiflow-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@
"stop", "marker", "clipPath", "mask", "pattern", "use", "symbol",
]);

/**
* Detach every tracked listener for `handle` from `node` and drop the
* `listeners` map entries. Shared by `destroyNode` and `animateExit` —
* both destruction paths must run the same sweep or entries leak. The
* handle prefix is parsed numerically: a startsWith match would hit
* handle 1 against keys for 10/11/etc.
*/
function detachListeners(handle, node) {
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
}

/**
* Apply a single patch. The opcode is `p.op`; field names match the
* Swift-side `PatchSerializer.encode(...)` contract.
Expand Down Expand Up @@ -165,31 +186,20 @@
case "destroyNode": {
// Symmetric with removeHandler: detach every tracked listener for
// this handle from the DOM node AND drop the map entries.
// Previously this case only deleted from the map, leaving the DOM
// bindings until GC -- mostly harmless but inconsistent with
// removeHandler and falsified the comment that claimed "Detach any
// listeners". Also: parse the handle prefix numerically (was
// startsWith-based, which would match handle 1 against keys for
// 10/11/etc. once handles cross 10).
const node = nodes.get(p.handle);
for (const key of Array.from(listeners.keys())) {
const sep = key.indexOf(":");
if (sep < 0) continue;
if (Number(key.slice(0, sep)) !== p.handle) continue;
const event = key.slice(sep + 1);
const fn = listeners.get(key);
if (node !== undefined && fn !== undefined) {
node.removeEventListener(event, fn);
}
listeners.delete(key);
}
detachListeners(p.handle, nodes.get(p.handle));
nodes.delete(p.handle);
return;
}
case "animateExit": {
const node = nodes.get(p.handle);
const parent = nodes.get(p.parentHandle);
if (!node) return;
// The Swift side evicts this node's handler ids when it emits the
// patch (destroyNode is suppressed for the animated root), so its
// listeners must be detached NOW — kept through the animation
// window they dispatch into evicted ids and their map entries
// outlive the node.
detachListeners(p.handle, node);
node.style.animation = p.animation;
setTimeout(function () {
if (parent && node.parentNode === parent) {
Expand Down
Loading
Loading