|
| 1 | +# Async Destruction & The Trap Pattern |
| 2 | + |
| 3 | +In single-page applications, handling the lifecycle of asynchronous operations is a critical challenge. A component might trigger a network request (like `fetch`) or a dynamic import, but be destroyed by the user (e.g., navigating away) before that operation completes. |
| 4 | + |
| 5 | +If the callback for that operation attempts to modify the component's state (e.g., `this.setState()`, `this.vdom = ...`), it will likely throw an error because the instance no longer exists or is in a broken state. Worse, it can lead to memory leaks or "zombie" processes that consume resources unnecessarily. |
| 6 | + |
| 7 | +Neo.mjs provides a robust, built-in mechanism to handle this: **The Trap Pattern**. |
| 8 | + |
| 9 | +## The Problem: Zombie Callbacks |
| 10 | + |
| 11 | +Consider this common scenario in a component or controller: |
| 12 | + |
| 13 | +```javascript |
| 14 | +// BAD: Unsafe async operation |
| 15 | +async loadData() { |
| 16 | + // 1. Start a network request |
| 17 | + const response = await fetch('/api/data'); |
| 18 | + const data = await response.json(); |
| 19 | + |
| 20 | + // 2. DANGER ZONE: |
| 21 | + // If the component was destroyed while awaiting above, |
| 22 | + // 'this' might be destroyed. Calling setter methods or |
| 23 | + // triggering updates will throw an error. |
| 24 | + this.items = data; |
| 25 | +} |
| 26 | +``` |
| 27 | + |
| 28 | +If `this.destroy()` is called while `fetch` is pending, the execution resumes after the `await` on a dead instance. |
| 29 | + |
| 30 | +## The Solution: `this.trap()` |
| 31 | + |
| 32 | +`Neo.core.Base`, the ancestor of almost every class in the framework (Components, Controllers, Stores), implements a method called `trap()`. |
| 33 | + |
| 34 | +`trap()` acts as a lifecycle-aware guard for Promises. |
| 35 | + |
| 36 | +1. It wraps the Promise you pass to it. |
| 37 | +2. If the Promise resolves **while the component is alive**, it returns the result normally. |
| 38 | +3. If the component is **destroyed** (or currently destroying) before the Promise resolves, `trap()` **rejects** the Promise with a specific symbol: `Neo.isDestroyed`. |
| 39 | + |
| 40 | +## Usage Examples |
| 41 | + |
| 42 | +### Basic Fetch |
| 43 | + |
| 44 | +Here is the corrected version of the previous example using `trap()`: |
| 45 | + |
| 46 | +```javascript |
| 47 | +// GOOD: Trapped async operation |
| 48 | +async loadData() { |
| 49 | + let me = this, // 'me' reference is standard in Neo.mjs |
| 50 | + data; |
| 51 | + |
| 52 | + try { |
| 53 | + // Wrap the fetch promise |
| 54 | + const response = await me.trap(fetch('/api/data')); |
| 55 | + |
| 56 | + // Wrap the json parsing promise |
| 57 | + data = await me.trap(response.json()); |
| 58 | + |
| 59 | + // Safe to use 'me' here, because trap() ensured we are still alive |
| 60 | + me.items = data; |
| 61 | + |
| 62 | + } catch (err) { |
| 63 | + // Gracefully handle the destruction case |
| 64 | + if (err !== Neo.isDestroyed) { |
| 65 | + console.error('Real error occurred:', err); |
| 66 | + } |
| 67 | + // If err === Neo.isDestroyed, we just stop. |
| 68 | + // No further code executes, effectively killing the "zombie" logic. |
| 69 | + } |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +### Dynamic Imports |
| 74 | + |
| 75 | +Dynamic imports are also asynchronous and should be trapped if the loaded module is used to update the instance. |
| 76 | + |
| 77 | +```javascript |
| 78 | +async loadChartEngine() { |
| 79 | + let me = this, |
| 80 | + module; |
| 81 | + |
| 82 | + try { |
| 83 | + // If the user closes the view while the chart engine is downloading, |
| 84 | + // this will reject, and we won't try to instantiate a chart on a dead view. |
| 85 | + module = await me.trap(import('amcharts/amcharts4.mjs')); |
| 86 | + |
| 87 | + me.chartEngine = module.default; |
| 88 | + me.renderChart(); |
| 89 | + |
| 90 | + } catch (err) { |
| 91 | + if (err !== Neo.isDestroyed) { |
| 92 | + console.error('Failed to load chart engine', err); |
| 93 | + } |
| 94 | + } |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### Promise.all |
| 99 | + |
| 100 | +You can trap combined promises as well. |
| 101 | + |
| 102 | +```javascript |
| 103 | +async loadAllData() { |
| 104 | + let me = this; |
| 105 | + |
| 106 | + try { |
| 107 | + const [users, projects] = await me.trap(Promise.all([ |
| 108 | + fetch('/api/users').then(r => r.json()), |
| 109 | + fetch('/api/projects').then(r => r.json()) |
| 110 | + ])); |
| 111 | + |
| 112 | + me.store.users = users; |
| 113 | + me.store.projects = projects; |
| 114 | + |
| 115 | + } catch (err) { |
| 116 | + if (err !== Neo.isDestroyed) { |
| 117 | + throw err; |
| 118 | + } |
| 119 | + } |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +## Best Practices |
| 124 | + |
| 125 | +1. **Trap Early, Trap Often**: Wrap *every* asynchronous boundary that crosses into `this` context access. |
| 126 | +2. **Separate `fetch` and `json()`**: Both are async points. It is safest to trap them individually or chain them inside a single trapped promise if preferred. |
| 127 | +3. **Check `Neo.isDestroyed`**: In your `catch` block, always check if the error is `Neo.isDestroyed` to silence expected lifecycle interruptions. |
| 128 | +4. **Controllers**: `Neo.controller.Base` inherits from `core.Base`, so ViewControllers have full access to `trap()`. This is the most common place to use it for fetching view data. |
| 129 | +5. **Stores**: `Neo.data.Store` uses `trap()` internally for its `load()` method, but if you are implementing custom data loading logic in a store, you should use it too. |
| 130 | + |
| 131 | +## Architecture Note |
| 132 | + |
| 133 | +The `Neo.isDestroyed` symbol is globally available (assigned to `Neo` in `src/Neo.mjs`). The global error handler in `src/Neo.mjs` is also configured to ignore unhandled rejections if the reason is `Neo.isDestroyed`, ensuring your console remains clean of "Uncaught (in promise)" errors for valid lifecycle cancellations. |
0 commit comments