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.
@@ -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
@@ -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
- this.pushTransaction(new SendEvent(path, this, name, args, parent, opts));
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
- this.pushTransaction(new BubbleEvent(path, this, name, args, parent, newOpts, targetPath));
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 pushRequest(path, name, args = [], opts = {}, parent = null) {
2936
- const curRoot = this.state.val;
2937
- const txnPath = path.toTransactionPath();
2938
- const curLeaf = txnPath.lookup(curRoot);
2939
- const handler = this.comps.getRequestFor(curLeaf, name) ?? mkReq404(name);
2940
- const reqCtx = new RequestContext(path, this, parent, curRoot);
2941
- const resHandlerName = opts?.onResName ?? name;
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 result = await handler.fn.apply(null, [...args, reqCtx]);
2950
- push(opts?.onOkName, resHandlerName, result, result, null);
2951
- } catch (error) {
2952
- push(opts?.onErrorName, resHandlerName, error, null, error);
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
- const curState = this.state.val;
2964
- const newState = transaction.run(curState, this.comps);
2965
- if (newState !== undefined) {
2966
- this.state.set(newState, { transaction });
2967
- transaction.afterTransaction();
2968
- } else
2969
- console.warn("undefined new state", { curState, transaction });
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._task = null;
3040
+ this._completion = null;
2991
3041
  }
2992
- get task() {
2993
- this._task ??= new Task;
2994
- return this._task;
3042
+ get completion() {
3043
+ this._completion ??= new Completion;
3044
+ return this._completion;
2995
3045
  }
2996
- getCompletionPromise() {
2997
- return this.task.promise;
3046
+ whenSettled() {
3047
+ return this.completion.whenSettled();
2998
3048
  }
2999
- setParent(parentTransaction) {
3000
- this.parentTransaction = parentTransaction;
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._task?.complete?.({ value: newLeaf, old: curLeaf });
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 Task {
3213
+ class InputDispatchEvent extends NameArgsTransaction {
3214
+ handlerProp = "input";
3215
+ }
3216
+
3217
+ class Completion {
3165
3218
  constructor() {
3166
- this.deps = [];
3167
- this.val = this.resolve = this.reject = null;
3168
- this.promise = new Promise((res, rej) => {
3169
- this.resolve = res;
3170
- this.reject = rej;
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.isCompleted = false;
3235
+ return this._selfPromise;
3173
3236
  }
3174
- addDep(task) {
3175
- console.assert(!this.isCompleted, "addDep for completed task", this, task);
3176
- this.deps.push(task);
3177
- task.promise.then((_) => this._check());
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
- complete(val) {
3245
+ markSelfSettled(val) {
3246
+ if (this.selfSettled)
3247
+ return;
3248
+ this.selfSettled = true;
3180
3249
  this.val = val;
3181
- this._check();
3250
+ this._selfResolve?.(val);
3251
+ }
3252
+ ensureSelfSettled() {
3253
+ if (!this.selfSettled)
3254
+ this.markSelfSettled(this.val);
3182
3255
  }
3183
- _check() {
3184
- if (this.deps.every((task) => task.isCompleted)) {
3185
- this.isCompleted = true;
3186
- this.resolve(this);
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,