phoenix_live_view 0.20.0 → 0.20.2

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.
@@ -5,10 +5,12 @@ import {
5
5
  EVENTS,
6
6
  PHX_COMPONENT,
7
7
  PHX_SKIP,
8
+ PHX_MAGIC_ID,
8
9
  REPLY,
9
10
  STATIC,
10
11
  TITLE,
11
12
  STREAM,
13
+ ROOT,
12
14
  } from "./constants"
13
15
 
14
16
  import {
@@ -17,6 +19,122 @@ import {
17
19
  isCid,
18
20
  } from "./utils"
19
21
 
22
+ const VOID_TAGS = new Set([
23
+ "area",
24
+ "base",
25
+ "br",
26
+ "col",
27
+ "command",
28
+ "embed",
29
+ "hr",
30
+ "img",
31
+ "input",
32
+ "keygen",
33
+ "link",
34
+ "meta",
35
+ "param",
36
+ "source",
37
+ "track",
38
+ "wbr"
39
+ ])
40
+ const endingTagNameChars = new Set([">", "/", " ", "\n", "\t", "\r"])
41
+ const quoteChars = new Set(["'", '"'])
42
+
43
+ export let modifyRoot = (html, attrs, clearInnerHTML) => {
44
+ let i = 0
45
+ let insideComment = false
46
+ let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML
47
+ while(i < html.length){
48
+ let char = html.charAt(i)
49
+ if(insideComment){
50
+ if(char === "-" && html.slice(i, i + 3) === "-->"){
51
+ insideComment = false
52
+ i += 3
53
+ } else {
54
+ i++
55
+ }
56
+ } else if(char === "<" && html.slice(i, i + 4) === "<!--"){
57
+ insideComment = true
58
+ i += 4
59
+ } else if(char === "<"){
60
+ beforeTag = html.slice(0, i)
61
+ let iAtOpen = i
62
+ i++
63
+ for(i; i < html.length; i++){
64
+ if(endingTagNameChars.has(html.charAt(i))){ break }
65
+ }
66
+ tagNameEndsAt = i
67
+ tag = html.slice(iAtOpen + 1, tagNameEndsAt)
68
+ // Scan the opening tag for id, if there is any
69
+ for(i; i < html.length; i++){
70
+ if(html.charAt(i) === ">" ){ break }
71
+ if(html.charAt(i) === "="){
72
+ let isId = html.slice(i - 3, i) === " id"
73
+ i++;
74
+ let char = html.charAt(i)
75
+ if (quoteChars.has(char)) {
76
+ let attrStartsAt = i
77
+ i++
78
+ for(i; i < html.length; i++){
79
+ if(html.charAt(i) === char){ break }
80
+ }
81
+ if (isId) {
82
+ id = html.slice(attrStartsAt + 1, i)
83
+ break
84
+ }
85
+ }
86
+ }
87
+ }
88
+ break
89
+ } else {
90
+ i++
91
+ }
92
+ }
93
+ if(!tag){ throw new Error(`malformed html ${html}`) }
94
+
95
+ let closeAt = html.length - 1
96
+ insideComment = false
97
+ while(closeAt >= beforeTag.length + tag.length){
98
+ let char = html.charAt(closeAt)
99
+ if(insideComment){
100
+ if(char === "-" && html.slice(closeAt - 3, closeAt) === "<!-"){
101
+ insideComment = false
102
+ closeAt -= 4
103
+ } else {
104
+ closeAt -= 1
105
+ }
106
+ } else if(char === ">" && html.slice(closeAt - 2, closeAt) === "--"){
107
+ insideComment = true
108
+ closeAt -= 3
109
+ } else if(char === ">"){
110
+ break
111
+ } else {
112
+ closeAt -= 1
113
+ }
114
+ }
115
+ afterTag = html.slice(closeAt + 1, html.length)
116
+
117
+ let attrsStr =
118
+ Object.keys(attrs)
119
+ .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`)
120
+ .join(" ")
121
+
122
+ if(clearInnerHTML){
123
+ // Keep the id if any
124
+ let idAttrStr = id ? ` id="${id}"` : "";
125
+ if(VOID_TAGS.has(tag)){
126
+ newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>`
127
+ } else {
128
+ newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}></${tag}>`
129
+ }
130
+ } else {
131
+ let rest = html.slice(tagNameEndsAt, closeAt + 1)
132
+ newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`
133
+ }
134
+
135
+ return [newHTML, beforeTag, afterTag]
136
+ }
137
+
20
138
  export default class Rendered {
21
139
  static extract(diff){
22
140
  let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff
@@ -29,20 +147,21 @@ export default class Rendered {
29
147
  constructor(viewId, rendered){
30
148
  this.viewId = viewId
31
149
  this.rendered = {}
150
+ this.magicId = 0
32
151
  this.mergeDiff(rendered)
33
152
  }
34
153
 
35
154
  parentViewId(){ return this.viewId }
36
155
 
37
156
  toString(onlyCids){
38
- let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids)
157
+ let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {})
39
158
  return [str, streams]
40
159
  }
41
160
 
42
- recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids){
161
+ recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){
43
162
  onlyCids = onlyCids ? new Set(onlyCids) : null
44
163
  let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()}
45
- this.toOutputBuffer(rendered, null, output)
164
+ this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs)
46
165
  return [output.buffer, output.streams]
47
166
  }
48
167
 
@@ -90,10 +209,11 @@ export default class Rendered {
90
209
  }
91
210
 
92
211
  stat = tdiff[STATIC]
93
- ndiff = this.cloneMerge(tdiff, cdiff)
212
+ ndiff = this.cloneMerge(tdiff, cdiff, true)
94
213
  ndiff[STATIC] = stat
95
214
  } else {
96
- ndiff = cdiff[STATIC] !== undefined ? cdiff : this.cloneMerge(oldc[cid] || {}, cdiff)
215
+ ndiff = cdiff[STATIC] !== undefined || oldc[cid] === undefined ?
216
+ cdiff : this.cloneMerge(oldc[cid], cdiff, false)
97
217
  }
98
218
 
99
219
  cache[cid] = ndiff
@@ -121,23 +241,41 @@ export default class Rendered {
121
241
  target[key] = val
122
242
  }
123
243
  }
244
+ if(target[ROOT]){
245
+ target.newRender = true
246
+ }
124
247
  }
125
248
 
126
- cloneMerge(target, source){
249
+ // Merges cid trees together, copying statics from source tree.
250
+ //
251
+ // The `pruneMagicId` is passed to control pruning the magicId of the
252
+ // target. We must always prune the magicId when we are sharing statics
253
+ // from another component. If not pruning, we replicate the logic from
254
+ // mutableMerge, where we set newRender to true if there is a root
255
+ // (effectively forcing the new version to be rendered instead of skipped)
256
+ //
257
+ cloneMerge(target, source, pruneMagicId){
127
258
  let merged = {...target, ...source}
128
259
  for(let key in merged){
129
260
  let val = source[key]
130
261
  let targetVal = target[key]
131
262
  if(isObject(val) && val[STATIC] === undefined && isObject(targetVal)){
132
- merged[key] = this.cloneMerge(targetVal, val)
263
+ merged[key] = this.cloneMerge(targetVal, val, pruneMagicId)
133
264
  }
134
265
  }
266
+ if(pruneMagicId){
267
+ delete merged.magicId
268
+ delete merged.newRender
269
+ } else if(target[ROOT]){
270
+ merged.newRender = true
271
+ }
135
272
  return merged
136
273
  }
137
274
 
138
275
  componentToString(cid){
139
- let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null, false)
140
- return [str, streams]
276
+ let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null)
277
+ let [strippedHTML, _before, _after] = modifyRoot(str, {})
278
+ return [strippedHTML, streams]
141
279
  }
142
280
 
143
281
  pruneCIDs(cids){
@@ -158,16 +296,53 @@ export default class Rendered {
158
296
  }
159
297
  }
160
298
 
161
- toOutputBuffer(rendered, templates, output){
299
+ nextMagicID(){
300
+ this.magicId++
301
+ return `${this.parentViewId()}-${this.magicId}`
302
+ }
303
+
304
+ // Converts rendered tree to output buffer.
305
+ //
306
+ // changeTracking controls if we can apply the PHX_SKIP optimization.
307
+ // It is disabled for comprehensions since we must re-render the entire collection
308
+ // and no invidial element is tracked inside the comprehension.
309
+ toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){
162
310
  if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) }
163
311
  let {[STATIC]: statics} = rendered
164
312
  statics = this.templateStatic(statics, templates)
313
+ let isRoot = rendered[ROOT]
314
+ let prevBuffer = output.buffer
315
+ if(isRoot){ output.buffer = "" }
316
+
317
+ if(changeTracking && isRoot && !rendered.magicId){
318
+ rendered.newRender = true
319
+ rendered.magicId = this.nextMagicID()
320
+ }
165
321
 
166
322
  output.buffer += statics[0]
167
323
  for(let i = 1; i < statics.length; i++){
168
- this.dynamicToBuffer(rendered[i - 1], templates, output)
324
+ this.dynamicToBuffer(rendered[i - 1], templates, output, changeTracking)
169
325
  output.buffer += statics[i]
170
326
  }
327
+
328
+ // Applies the root tag "skip" optimization if supported, which clears
329
+ // the root tag attributes and innerHTML, and only maintains the magicId.
330
+ // We can only skip when changeTracking is supported (outside of a comprehension),
331
+ // and when the root element hasn't experienced an unrendered merge (newRender true).
332
+ if(isRoot){
333
+ let skip = false
334
+ let attrs
335
+ if(changeTracking || Object.keys(rootAttrs).length > 0){
336
+ skip = !rendered.newRender
337
+ attrs = {[PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs}
338
+ } else {
339
+ attrs = rootAttrs
340
+ }
341
+ if(skip){ attrs[PHX_SKIP] = true}
342
+ let [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip)
343
+ rendered.newRender = false
344
+ output.buffer = prevBuffer + commentBefore + newRoot + commentAfter
345
+ }
171
346
  }
172
347
 
173
348
  comprehensionToBuffer(rendered, templates, output){
@@ -179,7 +354,12 @@ export default class Rendered {
179
354
  let dynamic = dynamics[d]
180
355
  output.buffer += statics[0]
181
356
  for(let i = 1; i < statics.length; i++){
182
- this.dynamicToBuffer(dynamic[i - 1], compTemplates, output)
357
+ // Inside a comprehension, we don't track how dynamics change
358
+ // over time (and features like streams would make that impossible
359
+ // unless we move the stream diffing away from morphdom),
360
+ // so we can't perform root change tracking.
361
+ let changeTracking = false
362
+ this.dynamicToBuffer(dynamic[i - 1], compTemplates, output, changeTracking)
183
363
  output.buffer += statics[i]
184
364
  }
185
365
  }
@@ -191,75 +371,40 @@ export default class Rendered {
191
371
  }
192
372
  }
193
373
 
194
- dynamicToBuffer(rendered, templates, output){
374
+ dynamicToBuffer(rendered, templates, output, changeTracking){
195
375
  if(typeof (rendered) === "number"){
196
376
  let [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids)
197
377
  output.buffer += str
198
378
  output.streams = new Set([...output.streams, ...streams])
199
379
  } else if(isObject(rendered)){
200
- this.toOutputBuffer(rendered, templates, output)
380
+ this.toOutputBuffer(rendered, templates, output, changeTracking, {})
201
381
  } else {
202
382
  output.buffer += rendered
203
383
  }
204
384
  }
205
385
 
206
- recursiveCIDToString(components, cid, onlyCids, allowRootComments = true){
386
+ recursiveCIDToString(components, cid, onlyCids){
207
387
  let component = components[cid] || logError(`no component for CID ${cid}`, components)
208
- let template = document.createElement("template")
209
- let [html, streams] = this.recursiveToString(component, components, onlyCids)
210
- template.innerHTML = html
211
- let container = template.content
388
+ let attrs = {[PHX_COMPONENT]: cid}
212
389
  let skip = onlyCids && !onlyCids.has(cid)
213
-
214
- let [hasChildNodes, hasChildComponents] =
215
- Array.from(container.childNodes).reduce(([hasNodes, hasComponents], child, i) => {
216
- if(child.nodeType === Node.ELEMENT_NODE){
217
- if(child.getAttribute(PHX_COMPONENT)){
218
- return [hasNodes, true]
219
- }
220
- child.setAttribute(PHX_COMPONENT, cid)
221
- if(!child.id){ child.id = `${this.parentViewId()}-${cid}-${i}` }
222
- if(skip){
223
- child.setAttribute(PHX_SKIP, "")
224
- child.innerHTML = ""
225
- }
226
- return [true, hasComponents]
227
- } else if(child.nodeType === Node.COMMENT_NODE){
228
- // we have to strip root comments when rendering a component directly
229
- // for patching because the morphdom target must be exactly the root entrypoint
230
- if(!allowRootComments){ child.remove() }
231
- return [hasNodes, hasComponents]
232
- } else {
233
- if(child.nodeValue.trim() !== ""){
234
- logError("only HTML element tags are allowed at the root of components.\n\n" +
235
- `got: "${child.nodeValue.trim()}"\n\n` +
236
- "within:\n", template.innerHTML.trim())
237
- child.replaceWith(this.createSpan(child.nodeValue, cid))
238
- return [true, hasComponents]
239
- } else {
240
- child.remove()
241
- return [hasNodes, hasComponents]
242
- }
243
- }
244
- }, [false, false])
245
-
246
- if(!hasChildNodes && !hasChildComponents){
247
- logError("expected at least one HTML element tag inside a component, but the component is empty:\n",
248
- template.innerHTML.trim())
249
- return [this.createSpan("", cid).outerHTML, streams]
250
- } else if(!hasChildNodes && hasChildComponents){
251
- logError("expected at least one HTML element tag directly inside a component, but only subcomponents were found. A component must render at least one HTML tag directly inside itself.",
252
- template.innerHTML.trim())
253
- return [template.innerHTML, streams]
254
- } else {
255
- return [template.innerHTML, streams]
256
- }
257
- }
258
-
259
- createSpan(text, cid){
260
- let span = document.createElement("span")
261
- span.innerText = text
262
- span.setAttribute(PHX_COMPONENT, cid)
263
- return span
390
+ // Two optimization paths apply here:
391
+ //
392
+ // 1. The onlyCids optimization works by the server diff telling us only specific
393
+ // cid's have changed. This allows us to skip rendering any component that hasn't changed,
394
+ // which ultimately sets PHX_SKIP root attribute and avoids rendering the innerHTML.
395
+ //
396
+ // 2. The root PHX_SKIP optimization generalizes to all HEEx function components, and
397
+ // works in the same PHX_SKIP attribute fashion as 1, but the newRender tracking is done
398
+ // at the general diff merge level. If we merge a diff with new dynamics, we necessariy have
399
+ // experienced a change which must be a newRender, and thus we can't skip the render.
400
+ //
401
+ // Both optimization flows apply here. newRender is set based on the onlyCids optimization, and
402
+ // we track a deterministic magicId based on the cid.
403
+ component.newRender = !skip
404
+ component.magicId = `${this.parentViewId()}-c-${cid}`
405
+ let changeTracking = true
406
+ let [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs)
407
+
408
+ return [html, streams]
264
409
  }
265
- }
410
+ }
@@ -96,7 +96,8 @@ export default class UploadEntry {
96
96
  relative_path: this.file.webkitRelativePath,
97
97
  size: this.file.size,
98
98
  type: this.file.type,
99
- ref: this.ref
99
+ ref: this.ref,
100
+ meta: typeof(this.file.meta) === "function" ? this.file.meta() : undefined
100
101
  }
101
102
  }
102
103
 
@@ -115,9 +115,10 @@ export default class View {
115
115
  this.children = this.parent ? null : {}
116
116
  this.root.children[this.id] = {}
117
117
  this.channel = this.liveSocket.channel(`lv:${this.id}`, () => {
118
+ let url = this.href && this.expandURL(this.href)
118
119
  return {
119
- redirect: this.redirect ? this.href : undefined,
120
- url: this.redirect ? undefined : this.href || undefined,
120
+ redirect: this.redirect ? url : undefined,
121
+ url: this.redirect ? undefined : url || undefined,
121
122
  params: this.connectParams(liveReferer),
122
123
  session: this.getSession(),
123
124
  static: this.getStatic(),
@@ -340,7 +341,7 @@ export default class View {
340
341
  this.attachTrueDocEl()
341
342
  let patch = new DOMPatch(this, this.el, this.id, html, streams, null)
342
343
  patch.markPrunableContentForRemoval()
343
- this.performPatch(patch, false)
344
+ this.performPatch(patch, false, true)
344
345
  this.joinNewChildren()
345
346
  this.execNewMounted()
346
347
 
@@ -381,7 +382,7 @@ export default class View {
381
382
  if(newHook){ newHook.__mounted() }
382
383
  }
383
384
 
384
- performPatch(patch, pruneCids){
385
+ performPatch(patch, pruneCids, isJoinPatch = false){
385
386
  let removedEls = []
386
387
  let phxChildrenAdded = false
387
388
  let updatedHookIds = new Set()
@@ -414,7 +415,7 @@ export default class View {
414
415
  })
415
416
 
416
417
  patch.after("transitionsDiscarded", els => this.afterElementsRemoved(els, pruneCids))
417
- patch.perform()
418
+ patch.perform(isJoinPatch)
418
419
  this.afterElementsRemoved(removedEls, pruneCids)
419
420
 
420
421
  return phxChildrenAdded
@@ -808,7 +809,7 @@ export default class View {
808
809
  targetComponentID(target, targetCtx, opts = {}){
809
810
  if(isCid(targetCtx)){ return targetCtx }
810
811
 
811
- let cidOrSelector = target.getAttribute(this.binding("target"))
812
+ let cidOrSelector = opts.target || target.getAttribute(this.binding("target"))
812
813
  if(isCid(cidOrSelector)){
813
814
  return parseInt(cidOrSelector)
814
815
  } else if(targetCtx && (cidOrSelector !== null || opts.target)){
@@ -890,7 +891,7 @@ export default class View {
890
891
 
891
892
  pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback){
892
893
  let uploads
893
- let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx)
894
+ let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts)
894
895
  let refGenerator = () => this.putRef([inputEl, inputEl.form], "change", opts)
895
896
  let formData
896
897
  let meta = this.extractMeta(inputEl.form)
@@ -994,7 +995,7 @@ export default class View {
994
995
  let cid = this.targetComponentID(formEl, targetCtx)
995
996
  if(LiveUploader.hasUploadsInProgress(formEl)){
996
997
  let [ref, _els] = refGenerator()
997
- let push = () => this.pushFormSubmit(formEl, submitter, targetCtx, phxEvent, opts, onReply)
998
+ let push = () => this.pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply)
998
999
  return this.scheduleSubmit(formEl, ref, opts, push)
999
1000
  } else if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){
1000
1001
  let [ref, els] = refGenerator()
@@ -1062,13 +1063,25 @@ export default class View {
1062
1063
  })
1063
1064
  }
1064
1065
 
1065
- dispatchUploads(name, filesOrBlobs){
1066
- let inputs = DOM.findUploadInputs(this.el).filter(el => el.name === name)
1066
+ dispatchUploads(targetCtx, name, filesOrBlobs){
1067
+ let targetElement = this.targetCtxElement(targetCtx) || this.el;
1068
+ let inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name)
1067
1069
  if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) }
1068
1070
  else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) }
1069
1071
  else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) }
1070
1072
  }
1071
1073
 
1074
+ targetCtxElement(targetCtx) {
1075
+ if(isCid(targetCtx)){
1076
+ let [target] = DOM.findComponentNodeList(this.el, targetCtx)
1077
+ return target
1078
+ } else if(targetCtx) {
1079
+ return targetCtx
1080
+ } else {
1081
+ return null
1082
+ }
1083
+ }
1084
+
1072
1085
  pushFormRecovery(form, newCid, callback){
1073
1086
  this.liveSocket.withinOwners(form, (view, targetCtx) => {
1074
1087
  let phxChange = this.binding("change")
@@ -53,11 +53,13 @@ export default class ViewHook {
53
53
  }
54
54
 
55
55
  upload(name, files){
56
- return this.__view.dispatchUploads(name, files)
56
+ return this.__view.dispatchUploads(null, name, files)
57
57
  }
58
58
 
59
59
  uploadTo(phxTarget, name, files){
60
- return this.__view.withinTargets(phxTarget, view => view.dispatchUploads(name, files))
60
+ return this.__view.withinTargets(phxTarget, (view, targetCtx) => {
61
+ view.dispatchUploads(targetCtx, name, files)
62
+ })
61
63
  }
62
64
 
63
65
  __cleanup__(){
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "repository": {},
@@ -10,7 +10,7 @@
10
10
  "test.watch": "jest --watch"
11
11
  },
12
12
  "dependencies": {
13
- "morphdom": "2.7.0"
13
+ "morphdom": "2.7.1"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@babel/cli": "7.14.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",