tutuca 0.9.93 → 0.9.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tutuca-cli.js +230 -59
- package/dist/tutuca-dev.ext.js +1367 -1200
- package/dist/tutuca-dev.js +1367 -1200
- package/dist/tutuca-dev.min.js +2 -2
- package/dist/tutuca-extra.ext.js +186 -52
- package/dist/tutuca-extra.js +187 -53
- package/dist/tutuca-extra.min.js +1 -1
- package/dist/tutuca-storybook.js +69 -11
- package/dist/tutuca.ext.js +186 -52
- package/dist/tutuca.js +187 -53
- package/dist/tutuca.min.js +1 -1
- package/package.json +2 -1
- package/skill/tutuca/core.md +19 -1
- package/skill/tutuca/patterns/coordinate-components.md +5 -0
- package/skill/tutuca/patterns/handle-events.md +7 -0
- package/skill/tutuca/storybook.md +46 -0
- package/skill/tutuca/testing.md +33 -0
- package/skill/tutuca-source/tutuca.ext.js +186 -52
|
@@ -65,6 +65,8 @@ Item fields:
|
|
|
65
65
|
- `value` — required, the instance to render, usually `Comp.make({...})`.
|
|
66
66
|
- `view?` — selects a pushed named view, rendered via `@push-view` in the card.
|
|
67
67
|
- `requestHandlers?` — per-example request mocks (next section).
|
|
68
|
+
- `on?` — lifecycle hooks; messages sent to `value` as sections are navigated
|
|
69
|
+
([Lifecycle hooks](#lifecycle-hooks-on)).
|
|
68
70
|
|
|
69
71
|
The storybook sorts sections by title and renders a sidebar with a filter, so
|
|
70
72
|
one example item per meaningful state reads as a state matrix.
|
|
@@ -97,6 +99,50 @@ See [request-response.md](./request-response.md) for the handler contract (the
|
|
|
97
99
|
`ctx` is the handler's final argument). `tutuca storybook --dry-run --json`
|
|
98
100
|
lists each example's mocked names.
|
|
99
101
|
|
|
102
|
+
## Lifecycle hooks (`on`)
|
|
103
|
+
|
|
104
|
+
An item's optional `on` field declares messages dispatched to the example's
|
|
105
|
+
component (`value`) as the user navigates sections — for examples that need to be
|
|
106
|
+
"kicked" into a state (load data, open a panel, focus an input) rather than
|
|
107
|
+
constructed in it. Three phases:
|
|
108
|
+
|
|
109
|
+
- **`init`** — the first time a section is displayed, sent to each of its examples.
|
|
110
|
+
- **`resume`** — each subsequent time that section is re-displayed.
|
|
111
|
+
- **`suspend`** — when a section is navigated away from.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
items: [
|
|
115
|
+
{ title: "Loaded", value: Grid.make({}),
|
|
116
|
+
on: {
|
|
117
|
+
init: { request: [{ name: "load", args: [] }] }, // fetch on first show
|
|
118
|
+
resume: { send: [{ name: "refresh", args: [] }] }, // re-poll on return
|
|
119
|
+
suspend: { send: [{ name: "pause", args: [] }] }, // stop work when hidden
|
|
120
|
+
} },
|
|
121
|
+
]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Each phase holds **action buckets** — `send` (→ a `receive` handler), `bubble`,
|
|
125
|
+
`request` (→ a `response` handler), `input` — each an array of
|
|
126
|
+
`{ name, args?, opts? }`. `args` is a plain array, or a **function** `(self) =>
|
|
127
|
+
[...]` called with the example's component instance:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
on: { init: { send: [{ name: "select", args: (self) => [self.firstId()] }] } }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
For ordering **across** kinds, use `do` — an explicit sequence where each item
|
|
134
|
+
carries its own `type`:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
on: { init: { do: [
|
|
138
|
+
{ type: "send", name: "reset", args: [] },
|
|
139
|
+
{ type: "request", name: "load", args: [] }, // runs after reset
|
|
140
|
+
] } }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`request` actions honor the example's `requestHandlers` mocks. A phase message
|
|
144
|
+
with no matching handler on the component is a silent no-op.
|
|
145
|
+
|
|
100
146
|
## Stories as tests (`getTests`)
|
|
101
147
|
|
|
102
148
|
`getTests` runs through the same machinery as `tutuca test`; the storybook runs
|
package/skill/tutuca/testing.md
CHANGED
|
@@ -90,6 +90,39 @@ Comp.input.handlerName.call(comp, arg1, arg2, /* … */);
|
|
|
90
90
|
it does.
|
|
91
91
|
- Returned value is the next instance.
|
|
92
92
|
|
|
93
|
+
## Driving a full cascade (`drive`)
|
|
94
|
+
|
|
95
|
+
Direct `.call(comp, ...)` tests one handler in isolation. When you need a message
|
|
96
|
+
to fan out through real dispatch — a `request` that resolves and feeds its
|
|
97
|
+
`response`, a `send` that triggers more sends — `getTests` also injects an async
|
|
98
|
+
`drive` helper (alongside `describe`, `test`, `expect`):
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
export function getTests({ describe, test, expect, drive }) {
|
|
102
|
+
describe(Grid, () => {
|
|
103
|
+
test("init loads rows", async () => {
|
|
104
|
+
const settled = await drive(
|
|
105
|
+
Grid.make({ rows: [] }),
|
|
106
|
+
{ request: [{ name: "load", args: [] }] }, // an `on`-phase config
|
|
107
|
+
);
|
|
108
|
+
expect(settled.rows.size).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `drive(value, phase, opts?)` builds a transactor over `value`, dispatches the
|
|
115
|
+
phase's actions at the root, awaits the whole cascade (including async
|
|
116
|
+
requests), and returns the **settled** instance.
|
|
117
|
+
- `phase` is the same shape as an example's `on.init`
|
|
118
|
+
(`{ send, bubble, request, input, do }`; see
|
|
119
|
+
[storybook.md](./storybook.md#lifecycle-hooks-on)). `args` may be a function
|
|
120
|
+
`(self) => [...]`.
|
|
121
|
+
- `request` actions resolve against the module's `getRequestHandlers()`.
|
|
122
|
+
- `opts.onMessage(message, before, after)` observes every committed transaction —
|
|
123
|
+
`message` is `{ kind, name, args, path }`, `before`/`after` are the root values
|
|
124
|
+
around its commit — handy for asserting the message/state trace.
|
|
125
|
+
|
|
93
126
|
## Testing iteration handlers
|
|
94
127
|
|
|
95
128
|
`alter` handlers run inside `@each` / `@when` / `@loop-with` /
|
|
@@ -2920,36 +2920,81 @@ class Transactor {
|
|
|
2920
2920
|
this.transactions = [];
|
|
2921
2921
|
this.state = new State(rootValue);
|
|
2922
2922
|
this.onTransactionPushed = () => {};
|
|
2923
|
+
this._inflight = new Set;
|
|
2923
2924
|
}
|
|
2924
2925
|
pushTransaction(t) {
|
|
2925
2926
|
this.transactions.push(t);
|
|
2926
2927
|
this.onTransactionPushed(t);
|
|
2927
2928
|
}
|
|
2929
|
+
_link(child, parent) {
|
|
2930
|
+
if (parent) {
|
|
2931
|
+
const release = parent.completion.track();
|
|
2932
|
+
child.completion.whenSubtreeSettled().then(release);
|
|
2933
|
+
}
|
|
2934
|
+
return child;
|
|
2935
|
+
}
|
|
2928
2936
|
pushSend(path, name, args = [], opts = {}, parent = null) {
|
|
2929
|
-
|
|
2937
|
+
const t = new SendEvent(path, this, name, args, parent, opts);
|
|
2938
|
+
this.pushTransaction(t);
|
|
2939
|
+
return this._link(t, parent);
|
|
2940
|
+
}
|
|
2941
|
+
pushInput(path, name, args = [], opts = {}, parent = null) {
|
|
2942
|
+
const t = new InputDispatchEvent(path, this, name, args, parent, opts);
|
|
2943
|
+
this.pushTransaction(t);
|
|
2944
|
+
return this._link(t, parent);
|
|
2930
2945
|
}
|
|
2931
2946
|
pushBubble(path, name, args = [], opts = {}, parent = null, targetPath = null) {
|
|
2932
2947
|
const newOpts = opts.skipSelf ? { ...opts, skipSelf: false } : opts;
|
|
2933
|
-
|
|
2948
|
+
const t = new BubbleEvent(path, this, name, args, parent, newOpts, targetPath);
|
|
2949
|
+
this.pushTransaction(t);
|
|
2950
|
+
return this._link(t, parent);
|
|
2951
|
+
}
|
|
2952
|
+
pushRequest(path, name, args = [], opts = {}, parent = null) {
|
|
2953
|
+
const release = parent ? parent.completion.track() : null;
|
|
2954
|
+
const p = this._runRequest(path, name, args, opts, parent, release);
|
|
2955
|
+
this._inflight.add(p);
|
|
2956
|
+
p.finally(() => this._inflight.delete(p));
|
|
2957
|
+
return p;
|
|
2958
|
+
}
|
|
2959
|
+
async settle(maxTurns = 1e4) {
|
|
2960
|
+
while ((this.hasPendingTransactions || this._inflight.size) && maxTurns-- > 0) {
|
|
2961
|
+
while (this.hasPendingTransactions)
|
|
2962
|
+
this.transactNext();
|
|
2963
|
+
if (this._inflight.size)
|
|
2964
|
+
await Promise.allSettled([...this._inflight]);
|
|
2965
|
+
}
|
|
2934
2966
|
}
|
|
2935
|
-
async
|
|
2936
|
-
|
|
2937
|
-
const
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
const resPath = opts?.livePath ? null : txnPath.pinKeys(curRoot);
|
|
2943
|
-
const push = (specificName, baseName, singleArg, result, error) => {
|
|
2944
|
-
const resArgs = specificName ? [singleArg] : [result, error];
|
|
2945
|
-
const t = new ResponseEvent(path, this, specificName ?? baseName, resArgs, parent, resPath);
|
|
2946
|
-
this.pushTransaction(t);
|
|
2967
|
+
async _runRequest(path, name, args = [], opts = {}, parent = null, release = null) {
|
|
2968
|
+
let released = false;
|
|
2969
|
+
const transfer = (t) => {
|
|
2970
|
+
if (release) {
|
|
2971
|
+
released = true;
|
|
2972
|
+
t.completion.whenSubtreeSettled().then(release);
|
|
2973
|
+
}
|
|
2947
2974
|
};
|
|
2948
2975
|
try {
|
|
2949
|
-
const
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2976
|
+
const curRoot = this.state.val;
|
|
2977
|
+
const txnPath = path.toTransactionPath();
|
|
2978
|
+
const curLeaf = txnPath.lookup(curRoot);
|
|
2979
|
+
const handler = this.comps.getRequestFor(curLeaf, name) ?? mkReq404(name);
|
|
2980
|
+
const reqCtx = new RequestContext(path, this, parent, curRoot);
|
|
2981
|
+
const resHandlerName = opts?.onResName ?? name;
|
|
2982
|
+
const resPath = opts?.livePath ? null : txnPath.pinKeys(curRoot);
|
|
2983
|
+
const push = (specificName, baseName, singleArg, result, error) => {
|
|
2984
|
+
const resArgs = specificName ? [singleArg] : [result, error];
|
|
2985
|
+
const t = new ResponseEvent(path, this, specificName ?? baseName, resArgs, parent, resPath);
|
|
2986
|
+
transfer(t);
|
|
2987
|
+
this.pushTransaction(t);
|
|
2988
|
+
};
|
|
2989
|
+
try {
|
|
2990
|
+
const result = await handler.fn.apply(null, [...args, reqCtx]);
|
|
2991
|
+
push(opts?.onOkName, resHandlerName, result, result, null);
|
|
2992
|
+
} catch (error) {
|
|
2993
|
+
push(opts?.onErrorName, resHandlerName, error, null, error);
|
|
2994
|
+
}
|
|
2995
|
+
} finally {
|
|
2996
|
+
if (release && !released)
|
|
2997
|
+
release();
|
|
2953
2998
|
}
|
|
2954
2999
|
}
|
|
2955
3000
|
get hasPendingTransactions() {
|
|
@@ -2960,13 +3005,18 @@ class Transactor {
|
|
|
2960
3005
|
this.transact(this.transactions.shift());
|
|
2961
3006
|
}
|
|
2962
3007
|
transact(transaction) {
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
3008
|
+
try {
|
|
3009
|
+
const curState = this.state.val;
|
|
3010
|
+
const newState = transaction.run(curState, this.comps);
|
|
3011
|
+
if (newState !== undefined) {
|
|
3012
|
+
this.state.set(newState, { transaction });
|
|
3013
|
+
transaction.afterTransaction();
|
|
3014
|
+
} else
|
|
3015
|
+
console.warn("undefined new state", { curState, transaction });
|
|
3016
|
+
} finally {
|
|
3017
|
+
transaction._completion?.ensureSelfSettled();
|
|
3018
|
+
transaction._completion?.releaseSelf();
|
|
3019
|
+
}
|
|
2970
3020
|
}
|
|
2971
3021
|
transactInputNow(path, event, eventHandler, dragInfo) {
|
|
2972
3022
|
this.transact(new InputEvent(path, event, eventHandler, this, dragInfo));
|
|
@@ -2987,18 +3037,17 @@ class Transaction {
|
|
|
2987
3037
|
this.path = path;
|
|
2988
3038
|
this.transactor = transactor;
|
|
2989
3039
|
this.parentTransaction = parentTransaction;
|
|
2990
|
-
this.
|
|
3040
|
+
this._completion = null;
|
|
2991
3041
|
}
|
|
2992
|
-
get
|
|
2993
|
-
this.
|
|
2994
|
-
return this.
|
|
3042
|
+
get completion() {
|
|
3043
|
+
this._completion ??= new Completion;
|
|
3044
|
+
return this._completion;
|
|
2995
3045
|
}
|
|
2996
|
-
|
|
2997
|
-
return this.
|
|
3046
|
+
whenSettled() {
|
|
3047
|
+
return this.completion.whenSettled();
|
|
2998
3048
|
}
|
|
2999
|
-
|
|
3000
|
-
this.
|
|
3001
|
-
parentTransaction.task.addDep(this.task);
|
|
3049
|
+
whenSubtreeSettled() {
|
|
3050
|
+
return this.completion.whenSubtreeSettled();
|
|
3002
3051
|
}
|
|
3003
3052
|
run(rootValue, comps) {
|
|
3004
3053
|
return this.updateRootValue(rootValue, comps);
|
|
@@ -3024,7 +3073,7 @@ class Transaction {
|
|
|
3024
3073
|
const txnPath = this.getTransactionPath();
|
|
3025
3074
|
const curLeaf = txnPath.lookup(curRoot);
|
|
3026
3075
|
const newLeaf = this.callHandler(curRoot, curLeaf, comps);
|
|
3027
|
-
this.
|
|
3076
|
+
this._completion?.markSelfSettled({ value: newLeaf, old: curLeaf });
|
|
3028
3077
|
return curLeaf !== newLeaf ? txnPath.setValue(curRoot, newLeaf) : curRoot;
|
|
3029
3078
|
}
|
|
3030
3079
|
lookupName(_name) {
|
|
@@ -3161,29 +3210,69 @@ class BubbleEvent extends SendEvent {
|
|
|
3161
3210
|
}
|
|
3162
3211
|
}
|
|
3163
3212
|
|
|
3164
|
-
class
|
|
3213
|
+
class InputDispatchEvent extends NameArgsTransaction {
|
|
3214
|
+
handlerProp = "input";
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
class Completion {
|
|
3165
3218
|
constructor() {
|
|
3166
|
-
this.
|
|
3167
|
-
this.
|
|
3168
|
-
this.
|
|
3169
|
-
|
|
3170
|
-
|
|
3219
|
+
this.val = undefined;
|
|
3220
|
+
this.selfSettled = false;
|
|
3221
|
+
this.subtreeSettled = false;
|
|
3222
|
+
this.pending = 1;
|
|
3223
|
+
this._selfResolve = null;
|
|
3224
|
+
this._selfPromise = null;
|
|
3225
|
+
this._subtreeResolve = null;
|
|
3226
|
+
this._subtreePromise = null;
|
|
3227
|
+
this._selfReleased = false;
|
|
3228
|
+
}
|
|
3229
|
+
whenSettled() {
|
|
3230
|
+
if (this.selfSettled)
|
|
3231
|
+
return Promise.resolve(this.val);
|
|
3232
|
+
this._selfPromise ??= new Promise((res) => {
|
|
3233
|
+
this._selfResolve = res;
|
|
3171
3234
|
});
|
|
3172
|
-
this.
|
|
3235
|
+
return this._selfPromise;
|
|
3173
3236
|
}
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3237
|
+
whenSubtreeSettled() {
|
|
3238
|
+
if (this.subtreeSettled)
|
|
3239
|
+
return Promise.resolve(this.val);
|
|
3240
|
+
this._subtreePromise ??= new Promise((res) => {
|
|
3241
|
+
this._subtreeResolve = res;
|
|
3242
|
+
});
|
|
3243
|
+
return this._subtreePromise;
|
|
3178
3244
|
}
|
|
3179
|
-
|
|
3245
|
+
markSelfSettled(val) {
|
|
3246
|
+
if (this.selfSettled)
|
|
3247
|
+
return;
|
|
3248
|
+
this.selfSettled = true;
|
|
3180
3249
|
this.val = val;
|
|
3181
|
-
this.
|
|
3250
|
+
this._selfResolve?.(val);
|
|
3251
|
+
}
|
|
3252
|
+
ensureSelfSettled() {
|
|
3253
|
+
if (!this.selfSettled)
|
|
3254
|
+
this.markSelfSettled(this.val);
|
|
3182
3255
|
}
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3256
|
+
track() {
|
|
3257
|
+
this.pending++;
|
|
3258
|
+
let done = false;
|
|
3259
|
+
return () => {
|
|
3260
|
+
if (done)
|
|
3261
|
+
return;
|
|
3262
|
+
done = true;
|
|
3263
|
+
this._release();
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
releaseSelf() {
|
|
3267
|
+
if (this._selfReleased)
|
|
3268
|
+
return;
|
|
3269
|
+
this._selfReleased = true;
|
|
3270
|
+
this._release();
|
|
3271
|
+
}
|
|
3272
|
+
_release() {
|
|
3273
|
+
if (--this.pending === 0) {
|
|
3274
|
+
this.subtreeSettled = true;
|
|
3275
|
+
this._subtreeResolve?.(this.val);
|
|
3187
3276
|
}
|
|
3188
3277
|
}
|
|
3189
3278
|
}
|
|
@@ -3222,6 +3311,9 @@ class Dispatcher {
|
|
|
3222
3311
|
requestAtPath(path, name, args, opts) {
|
|
3223
3312
|
return this.transactor.pushRequest(path, name, args, opts, this.parent);
|
|
3224
3313
|
}
|
|
3314
|
+
inputAtPath(path, name, args, opts) {
|
|
3315
|
+
return this.transactor.pushInput(path, name, args, opts, this.parent);
|
|
3316
|
+
}
|
|
3225
3317
|
lookupTypeFor(name, inst) {
|
|
3226
3318
|
return this.transactor.comps.getCompFor(inst).scope.lookupComponent(name);
|
|
3227
3319
|
}
|
|
@@ -3576,6 +3668,45 @@ import {
|
|
|
3576
3668
|
version
|
|
3577
3669
|
} from "immutable";
|
|
3578
3670
|
|
|
3671
|
+
// src/on.js
|
|
3672
|
+
var OP_KINDS = ["send", "bubble", "request", "input"];
|
|
3673
|
+
function phaseOps(phase) {
|
|
3674
|
+
const ops = [];
|
|
3675
|
+
for (const type of OP_KINDS)
|
|
3676
|
+
for (const a of phase[type] ?? [])
|
|
3677
|
+
ops.push({ type, ...a });
|
|
3678
|
+
for (const a of phase.do ?? [])
|
|
3679
|
+
ops.push(a);
|
|
3680
|
+
return ops;
|
|
3681
|
+
}
|
|
3682
|
+
function resolveArgs(args, self) {
|
|
3683
|
+
return typeof args === "function" ? args(self) ?? [] : args ?? [];
|
|
3684
|
+
}
|
|
3685
|
+
function dispatchPhase(dispatcher, targetPath, phase, self) {
|
|
3686
|
+
if (!phase)
|
|
3687
|
+
return;
|
|
3688
|
+
for (const op of phaseOps(phase)) {
|
|
3689
|
+
const args = resolveArgs(op.args, self);
|
|
3690
|
+
switch (op.type) {
|
|
3691
|
+
case "send":
|
|
3692
|
+
dispatcher.sendAtPath(targetPath, op.name, args, op.opts);
|
|
3693
|
+
break;
|
|
3694
|
+
case "bubble":
|
|
3695
|
+
dispatcher.sendAtPath(targetPath, op.name, args, {
|
|
3696
|
+
skipSelf: true,
|
|
3697
|
+
bubbles: true,
|
|
3698
|
+
...op.opts
|
|
3699
|
+
});
|
|
3700
|
+
break;
|
|
3701
|
+
case "request":
|
|
3702
|
+
dispatcher.requestAtPath(targetPath, op.name, args, op.opts);
|
|
3703
|
+
break;
|
|
3704
|
+
case "input":
|
|
3705
|
+
dispatcher.inputAtPath(targetPath, op.name, args, op.opts);
|
|
3706
|
+
break;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3579
3710
|
// src/oo.js
|
|
3580
3711
|
import { Map as IMap, Set as ISet, List, OrderedMap, Record } from "immutable";
|
|
3581
3712
|
var BAD_VALUE = Symbol("BadValue");
|
|
@@ -3989,8 +4120,10 @@ export {
|
|
|
3989
4120
|
test,
|
|
3990
4121
|
setIn,
|
|
3991
4122
|
set,
|
|
4123
|
+
resolveArgs,
|
|
3992
4124
|
removeIn,
|
|
3993
4125
|
remove,
|
|
4126
|
+
phaseOps,
|
|
3994
4127
|
mergeWith,
|
|
3995
4128
|
mergeDeepWith,
|
|
3996
4129
|
mergeDeep,
|
|
@@ -4023,6 +4156,7 @@ export {
|
|
|
4023
4156
|
getIn,
|
|
4024
4157
|
get,
|
|
4025
4158
|
fromJS,
|
|
4159
|
+
dispatchPhase,
|
|
4026
4160
|
css,
|
|
4027
4161
|
component,
|
|
4028
4162
|
collectIterBindings,
|