11/** @typedef {'head' | 'body' } PayloadType */
22/** @typedef {{ [key in PayloadType]: string } } AccumulatedContent */
33/** @typedef {{ start: number, end: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> } } Compaction */
4+ /**
5+ * @template T
6+ * @typedef {T | Promise<T> } MaybePromise<T>
7+ */
48
59/**
610 * Payloads are basically a tree of `string | Payload`s, where each `Payload` in the tree represents
711 * work that may or may not have completed. A payload can be {@link collect}ed to aggregate the
812 * content from itself and all of its children, but this will throw if any of the children are
9- * performing asynchronous work. A payload can also be collected asynchronously with
10- * {@link collect_async}, which will wait for all children to complete before collecting their
11- * contents.
13+ * performing asynchronous work. To asynchronously collect a payload, just `await` it.
1214 *
1315 * The `string` values within a payload are always associated with the {@link type} of that payload. To switch types,
1416 * call {@link child} with a different `type` argument.
1517 */
1618export class Payload {
1719 /**
20+ * The contents of the payload.
21+ * @type {(string | Payload)[] }
22+ */
23+ #out = [ ] ;
24+
25+ /**
26+ * The type of string content that this payload is accumulating.
1827 * @type {PayloadType }
1928 */
2029 type ;
@@ -23,17 +32,12 @@ export class Payload {
2332 parent ;
2433
2534 /**
26- * The contents of the payload.
27- * @type {(string | Payload)[] }
28- */
29- out = [ ] ;
30-
31- /**
32- * A promise that resolves when this payload's blocking asynchronous work is done.
33- * If this promise is not resolved, it is not safe to collect the payload from `out`.
34- * @type {Promise<void> | undefined }
35+ * Asynchronous work associated with this payload. `initial` is the promise from the function
36+ * this payload was passed to (if that function was async), and `followup` is any any additional
37+ * work from `compact` calls that needs to complete prior to collecting this payload's content.
38+ * @type {{ initial: Promise<void> | undefined, followup: Promise<void>[] | undefined } }
3539 */
36- promise ;
40+ promises = { initial : undefined , followup : undefined } ;
3741
3842 /**
3943 * State which is associated with the content tree as a whole.
@@ -65,72 +69,62 @@ export class Payload {
6569
6670 /**
6771 * Create a child payload. The child payload inherits the state from the parent,
68- * but has its own `out` array and `promise` property. The child payload is automatically
69- * inserted into the parent payload's `out` array.
70- * @param {(tree: Payload) => void | Promise<void> } render
72+ * but has its own content.
73+ * @param {(tree: Payload) => MaybePromise<void> } render
7174 * @param {PayloadType } [type]
7275 * @returns {void }
7376 */
7477 child ( render , type ) {
7578 const child = new Payload ( this . global , this . local , this , type ) ;
76- this . out . push ( child ) ;
79+ this . # out. push ( child ) ;
7780 const result = render ( child ) ;
7881 if ( result instanceof Promise ) {
79- child . promise = result ;
82+ child . promises . initial = result ;
8083 }
8184 }
8285
83- /** @param {string } content */
86+ /**
87+ * @param {(value: { head: string, body: string }) => void } onfulfilled
88+ */
89+ async then ( onfulfilled ) {
90+ const content = await Payload . #collect_content( [ this ] , this . type ) ;
91+ return onfulfilled ( content ) ;
92+ }
93+
94+ /**
95+ * @param {string } content
96+ */
8497 push ( content ) {
85- this . out . push ( content ) ;
98+ this . # out. push ( content ) ;
8699 }
87100
88101 /**
89102 * Compact everything between `start` and `end` into a single payload, then call `fn` with the result of that payload.
90103 * The compacted payload will be sync if all of the children are sync and {@link fn} is sync, otherwise it will be async.
91- * @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> } } args
104+ * @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent } } args
92105 */
93- compact ( { start, end = this . out . length , fn } ) {
106+ compact ( { start, end = this . # out. length , fn } ) {
94107 const child = new Payload ( this . global , this . local , this ) ;
95- const to_compact = this . out . splice ( start , end - start , child ) ;
96- const promises = Payload . #collect_promises( to_compact , [ ] ) ;
97-
98- const push_result = ( ) => {
99- const res = fn ( Payload . #collect_content( to_compact , this . type ) ) ;
100- if ( res instanceof Promise ) {
101- const promise = res . then ( ( resolved ) => {
102- Payload . #push_accumulated_content( child , resolved ) ;
103- } ) ;
104- return promise ;
105- } else {
106- Payload . #push_accumulated_content( child , res ) ;
107- }
108- } ;
109-
110- if ( promises . length > 0 ) {
111- // we have to wait for the accumulated work associated with all pruned branches to complete,
112- // then we can accumulate their content to compact it.
113- child . promise = Promise . all ( promises ) . then ( push_result ) ;
108+ const to_compact = this . #out. splice ( start , end - start , child ) ;
109+ const content = Payload . #collect_content( to_compact , this . type ) ;
110+
111+ if ( content instanceof Promise ) {
112+ const followup = content
113+ . then ( ( content ) => fn ( content ) )
114+ . then ( ( transformed_content ) =>
115+ Payload . #push_accumulated_content( child , transformed_content )
116+ ) ;
117+ ( this . promises . followup ??= [ ] ) . push ( followup ) ;
114118 } else {
115- push_result ( ) ;
119+ Payload . #push_accumulated_content ( child , fn ( content ) ) ;
116120 }
117121 }
118122
119123 /**
120124 * @returns {number[] }
121125 */
122126 get_path ( ) {
123- return this . parent ? [ ...this . parent . get_path ( ) , this . parent . out . indexOf ( this ) ] : [ ] ;
124- }
125-
126- /**
127- * Waits for all child payloads to finish their blocking asynchronous work, then returns the generated content.
128- * @returns {Promise<AccumulatedContent> }
129- */
130- async collect_async ( ) {
131- // TODO: Should probably use `Promise.allSettled` here just so we can report detailed errors
132- await Promise . all ( Payload . #collect_promises( this . out , this . promise ? [ this . promise ] : [ ] ) ) ;
133- return Payload . #collect_content( this . out , this . type ) ;
127+ return this . parent ? [ ...this . parent . get_path ( ) , this . parent . #out. indexOf ( this ) ] : [ ] ;
134128 }
135129
136130 /**
@@ -139,19 +133,19 @@ export class Payload {
139133 * @returns {AccumulatedContent }
140134 */
141135 collect ( ) {
142- const promises = Payload . #collect_promises ( this . out , this . promise ? [ this . promise ] : [ ] ) ;
143- if ( promises . length > 0 ) {
136+ const content = Payload . #collect_content ( this . # out, this . type ) ;
137+ if ( content instanceof Promise ) {
144138 // TODO is there a good way to report where this is? Probably by using some sort of loc or stack trace in `child` creation.
145139 throw new Error ( 'Encountered an asynchronous component while rendering synchronously' ) ;
146140 }
147141
148- return Payload . #collect_content ( this . out , this . type ) ;
142+ return content ;
149143 }
150144
151145 copy ( ) {
152146 const copy = new Payload ( this . global , this . local , this . parent , this . type ) ;
153- copy . out = this . out . map ( ( item ) => ( typeof item === 'string' ? item : item . copy ( ) ) ) ;
154- copy . promise = this . promise ;
147+ copy . # out = this . # out. map ( ( item ) => ( typeof item === 'string' ? item : item . copy ( ) ) ) ;
148+ copy . promises = this . promises ;
155149 return copy ;
156150 }
157151
@@ -161,45 +155,94 @@ export class Payload {
161155 subsume ( other ) {
162156 this . global . subsume ( other . global ) ;
163157 this . local = other . local ;
164- this . out = other . out . map ( ( item ) => {
158+ this . # out = other . # out. map ( ( item ) => {
165159 if ( typeof item !== 'string' ) {
166160 item . subsume ( item ) ;
167161 }
168162 return item ;
169163 } ) ;
170- this . promise = other . promise ;
164+ this . promises = other . promises ;
171165 this . type = other . type ;
172166 }
173167
168+ get length ( ) {
169+ return this . #out. length ;
170+ }
171+
174172 /**
173+ * Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string.
175174 * @param {(string | Payload)[] } items
176- * @param {Promise<void>[] } promises
177- * @returns {Promise<void>[] }
175+ * @param {PayloadType } current_type
176+ * @param {AccumulatedContent } content
177+ * @returns {MaybePromise<AccumulatedContent> }
178178 */
179- static #collect_promises( items , promises ) {
179+ static #collect_content( items , current_type , content = { head : '' , body : '' } ) {
180+ /** @type {MaybePromise<AccumulatedContent>[] } */
181+ const segments = [ ] ;
182+ let has_async = false ;
183+
184+ const flush = ( ) => {
185+ if ( content . head || content . body ) {
186+ segments . push ( content ) ;
187+ content = { head : '' , body : '' } ;
188+ }
189+ } ;
190+
180191 for ( const item of items ) {
181- if ( typeof item === 'string' ) continue ;
182- if ( item . promise ) {
183- promises . push ( item . promise ) ;
192+ if ( typeof item === 'string' ) {
193+ content [ current_type ] += item ;
194+ } else {
195+ flush ( ) ;
196+
197+ if ( item . promises . initial ) {
198+ has_async = true ;
199+ segments . push (
200+ Payload . #collect_content_async( [ item ] , current_type , { head : '' , body : '' } )
201+ ) ;
202+ } else {
203+ const sub = Payload . #collect_content( item . #out, item . type , { head : '' , body : '' } ) ;
204+ if ( sub instanceof Promise ) {
205+ has_async = true ;
206+ }
207+ segments . push ( sub ) ;
208+ }
184209 }
185- Payload . #collect_promises( item . out , promises ) ;
186210 }
187- return promises ;
211+
212+ flush ( ) ;
213+
214+ if ( has_async ) {
215+ return Promise . all ( segments ) . then ( ( content_array ) =>
216+ Payload . #squash_accumulated_content( content_array )
217+ ) ;
218+ }
219+
220+ // No async segments — combine synchronously
221+ return Payload . #squash_accumulated_content( /** @type {AccumulatedContent[] } */ ( segments ) ) ;
188222 }
189223
190224 /**
191225 * Collect all of the code from the `out` array and return it as a string.
192226 * @param {(string | Payload)[] } items
193227 * @param {PayloadType } current_type
194228 * @param {AccumulatedContent } content
195- * @returns {AccumulatedContent }
229+ * @returns {Promise< AccumulatedContent> }
196230 */
197- static #collect_content ( items , current_type , content = { head : '' , body : '' } ) {
231+ static async #collect_content_async ( items , current_type , content = { head : '' , body : '' } ) {
198232 for ( const item of items ) {
199233 if ( typeof item === 'string' ) {
200234 content [ current_type ] += item ;
201235 } else {
202- Payload . #collect_content( item . out , item . type , content ) ;
236+ if ( item . promises . initial ) {
237+ // this represents the async function that's modifying this payload.
238+ // we can't do anything until it's done and we know our `out` array is complete.
239+ await item . promises . initial ;
240+ }
241+ for ( const followup of item . promises . followup ?? [ ] ) {
242+ // this is sequential because `compact` could synchronously queue up additional followup work
243+ await followup ;
244+ }
245+ await Payload . #collect_content_async( item . #out, item . type , content ) ;
203246 }
204247 }
205248 return content ;
@@ -214,9 +257,24 @@ export class Payload {
214257 if ( ! content ) continue ;
215258 const child = new Payload ( tree . global , tree . local , tree , /** @type {PayloadType } */ ( type ) ) ;
216259 child . push ( content ) ;
217- tree . out . push ( child ) ;
260+ tree . # out. push ( child ) ;
218261 }
219262 }
263+
264+ /**
265+ * @param {AccumulatedContent[] } content_array
266+ * @returns {AccumulatedContent }
267+ */
268+ static #squash_accumulated_content( content_array ) {
269+ return content_array . reduce (
270+ ( acc , content ) => {
271+ acc . head += content . head ;
272+ acc . body += content . body ;
273+ return acc ;
274+ } ,
275+ { head : '' , body : '' }
276+ ) ;
277+ }
220278}
221279
222280export class TreeState {
0 commit comments