Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
be2d799
WIP
josevalim Jun 25, 2025
80a01d3
Revert "raise custom error for duplicate ID when using keyed comprehe…
josevalim Jun 25, 2025
a2b0f50
Tests
josevalim Jun 25, 2025
5766925
Revert unwanted stream changes
josevalim Jun 25, 2025
751cf19
Use paths when rendering
josevalim Jun 25, 2025
1d0c2fb
Add comprehension test
josevalim Jun 25, 2025
4079117
merge vars_changed
SteffenDE Jun 26, 2025
ce476a0
wip
SteffenDE Jun 26, 2025
560f2c5
add template sharing (copied from comprehensions)
SteffenDE Jun 27, 2025
ec146f0
move keyed count into keyed
SteffenDE Jun 27, 2025
586c11c
key :for without :key
SteffenDE Jun 27, 2025
a5d42b8
fix to_iodata and diff to iodata
SteffenDE Jun 27, 2025
6725db2
broken
SteffenDE Jun 27, 2025
066ed78
streams
SteffenDE Jun 27, 2025
e094c0f
special case streams to not store vars
SteffenDE Jun 27, 2025
5781eb8
fix flaky tests, add missing module
SteffenDE Jun 27, 2025
b4dac55
undo formatting
SteffenDE Jun 27, 2025
3fd6eb5
format
SteffenDE Jun 27, 2025
c06fe3e
update docs
SteffenDE Jun 27, 2025
3234102
always share templates, resolve on the client before merging
SteffenDE Jun 29, 2025
267dbfe
remove unused comprehensions
SteffenDE Jun 29, 2025
3636c98
fix mergeKeyed
SteffenDE Jun 29, 2025
71e7476
scope variable
SteffenDE Jun 30, 2025
895c7fc
no need for has_vars_changed
SteffenDE Jun 30, 2025
d0e58ba
bump build
SteffenDE Jun 30, 2025
ecbaf47
always share templates, resolve on the client before merging (#3866)
SteffenDE Jun 30, 2025
1445228
diff needs to be sent when the count changes
SteffenDE Jun 30, 2025
cb318e3
refactor
SteffenDE Jun 30, 2025
bb11c13
update test assertions
SteffenDE Jul 1, 2025
7b3f240
canonical print optimization
SteffenDE Jul 1, 2025
5328fda
fix remaining tests
SteffenDE Jul 1, 2025
0493ca8
we don't need the path
SteffenDE Jul 1, 2025
b889d7e
undo 1.19 formatting
SteffenDE Jul 1, 2025
a382324
fix js tests, don't rely on structuredClone
SteffenDE Jul 1, 2025
af2c38f
remove priv changes
SteffenDE Jul 1, 2025
9fea55c
Merge branch 'main' into sd-keyed-smart
SteffenDE Jul 1, 2025
8538165
raise for duplicate keys
SteffenDE Jul 1, 2025
a849f97
Merge branch 'main' into sd-keyed-smart
SteffenDE Jul 1, 2025
2e21496
always pass a map
SteffenDE Jul 1, 2025
e005997
use structuredClone for now
SteffenDE Jul 1, 2025
ae83f20
allow :key on everything except slots
SteffenDE Jul 1, 2025
654b354
update docs
SteffenDE Jul 1, 2025
1c75628
fix :key on slot error
SteffenDE Jul 3, 2025
be3c1f7
apply suggestions from code review
SteffenDE Jul 3, 2025
a539f0e
Merge branch 'main' into sd-keyed-smart
SteffenDE Jul 3, 2025
958343a
update docs
SteffenDE Jul 3, 2025
306dac8
share canonical print on client (#3876)
SteffenDE Jul 4, 2025
2ddfe0a
remove taint_vars
SteffenDE Jul 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion assets/js/phoenix_live_view/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ export const DEFAULTS = {
};
export const PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK];
// Rendered
export const DYNAMICS = "d";
export const STATIC = "s";
export const ROOT = "r";
export const COMPONENTS = "c";
export const KEYED = "k";
export const KEYED_COUNT = "kc";
export const EVENTS = "e";
export const REPLY = "r";
export const TITLE = "t";
Expand Down
173 changes: 131 additions & 42 deletions assets/js/phoenix_live_view/rendered.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
COMPONENTS,
DYNAMICS,
TEMPLATES,
EVENTS,
PHX_COMPONENT,
Expand All @@ -12,6 +11,8 @@ import {
TITLE,
STREAM,
ROOT,
KEYED,
KEYED_COUNT,
} from "./constants";

import { isObject, logError, isCid } from "./utils";
Expand Down Expand Up @@ -253,21 +254,81 @@ export default class Rendered {
}

doMutableMerge(target, source) {
for (const key in source) {
const val = source[key];
const targetVal = target[key];
const isObjVal = isObject(val);
if (isObjVal && val[STATIC] === undefined && isObject(targetVal)) {
this.doMutableMerge(targetVal, val);
} else {
target[key] = val;
if (source[KEYED]) {
this.mergeKeyed(target, source);
} else {
for (const key in source) {
const val = source[key];
const targetVal = target[key];
const isObjVal = isObject(val);
if (isObjVal && val[STATIC] === undefined && isObject(targetVal)) {
this.doMutableMerge(targetVal, val);
} else {
target[key] = val;
}
}
}
if (target[ROOT]) {
target.newRender = true;
}
}

clone(diff) {
if ("structuredClone" in window) {
return structuredClone(diff);
} else {
// fallback for jest
return JSON.parse(JSON.stringify(diff));
}
}

// keyed comprehensions
mergeKeyed(target, source) {
// we need to clone the target since elements can move and otherwise
// it could happen that we modify an element that we'll need to refer to
// later
const clonedTarget = this.clone(target);
Object.entries(source[KEYED]).forEach(([i, entry]) => {
if (i === KEYED_COUNT) {
return;
}
if (Array.isArray(entry)) {
// [old_idx, diff]
// moved with diff
const [old_idx, diff] = entry;
target[KEYED][i] = clonedTarget[KEYED][old_idx];
this.doMutableMerge(target[KEYED][i], diff);
} else if (typeof entry === "number") {
// moved without diff
const old_idx = entry;
target[KEYED][i] = clonedTarget[KEYED][old_idx];
} else if (typeof entry === "object") {
// diff, same position
if (!target[KEYED][i]) {
target[KEYED][i] = {};
}
this.doMutableMerge(target[KEYED][i], entry);
}
});
// drop extra entries
if (source[KEYED][KEYED_COUNT] < target[KEYED][KEYED_COUNT]) {
for (
let i = source[KEYED][KEYED_COUNT];
i < target[KEYED][KEYED_COUNT];
i++
) {
delete target[KEYED][i];
}
}
target[KEYED][KEYED_COUNT] = source[KEYED][KEYED_COUNT];
if (source[STREAM]) {
target[STREAM] = source[STREAM];
}
if (source[TEMPLATES]) {
target[TEMPLATES] = source[TEMPLATES];
}
}

// Merges cid trees together, copying statics from source tree.
//
// The `pruneMagicId` is passed to control pruning the magicId of the
Expand Down Expand Up @@ -336,14 +397,30 @@ export default class Rendered {
// Converts rendered tree to output buffer.
//
// changeTracking controls if we can apply the PHX_SKIP optimization.
// It is disabled for comprehensions since we must re-render the entire collection
// and no individual element is tracked inside the comprehension.
toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}) {
if (rendered[DYNAMICS]) {
return this.comprehensionToBuffer(rendered, templates, output);
if (rendered[KEYED]) {
return this.comprehensionToBuffer(
rendered,
templates,
output,
changeTracking,
);
}

// Templates are a way of sharing statics between multiple rendered structs.
// Since LiveView 1.1, those can also appear at the root - for example if one renders
// two comprehensions that can share statics.
// Whenever we find templates, we need to use them recursively. Also, templates can
// be sent for each diff, not only for the initial one. We don't want to merge them
// though, so we always resolve them and remove them from the rendered object.
if (rendered[TEMPLATES]) {
templates = rendered[TEMPLATES];
delete rendered[TEMPLATES];
}

let { [STATIC]: statics } = rendered;
statics = this.templateStatic(statics, templates);
rendered[STATIC] = statics;
const isRoot = rendered[ROOT];
const prevBuffer = output.buffer;
if (isRoot) {
Expand All @@ -365,7 +442,7 @@ export default class Rendered {

// Applies the root tag "skip" optimization if supported, which clears
// the root tag attributes and innerHTML, and only maintains the magicId.
// We can only skip when changeTracking is supported (outside of a comprehension),
// We can only skip when changeTracking is supported,
// and when the root element hasn't experienced an unrendered merge (newRender true).
if (isRoot) {
let skip = false;
Expand Down Expand Up @@ -393,41 +470,53 @@ export default class Rendered {
}
}

comprehensionToBuffer(rendered, templates, output) {
let {
[DYNAMICS]: dynamics,
[STATIC]: statics,
[STREAM]: stream,
} = rendered;
const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null];
statics = this.templateStatic(statics, templates);
const compTemplates = templates || rendered[TEMPLATES];
for (let d = 0; d < dynamics.length; d++) {
const dynamic = dynamics[d];
comprehensionToBuffer(rendered, templates, output, changeTracking) {
const keyedTemplates = templates || rendered[TEMPLATES];
const statics = this.templateStatic(rendered[STATIC], templates);
rendered[STATIC] = statics;
delete rendered[TEMPLATES];
let canonicalDiff;
for (let i = 0; i < rendered[KEYED][KEYED_COUNT]; i++) {
// this is another optimization where we assume the first element in
// the comprehension has a "canonical diff" that is shared with all
// following elements (if possible). The diff only contains the
// dynamic parts for the parts that can be shared, therefore we use
// cloneMerge to copy all eligilbe statics from the first diff into
// all subsequent ones.
if (i == 0) {
canonicalDiff = rendered[KEYED][i];
} else {
rendered[KEYED][i] = this.cloneMerge(
canonicalDiff,
rendered[KEYED][i],
true,
);
}
output.buffer += statics[0];
for (let i = 1; i < statics.length; i++) {
// Inside a comprehension, we don't track how dynamics change
// over time (and features like streams would make that impossible
// unless we move the stream diffing away from morphdom),
// so we can't perform root change tracking.
const changeTracking = false;
for (let j = 1; j < statics.length; j++) {
this.dynamicToBuffer(
dynamic[i - 1],
compTemplates,
rendered[KEYED][i][j - 1],
keyedTemplates,
output,
changeTracking,
);
output.buffer += statics[i];
output.buffer += statics[j];
}
}

if (
stream !== undefined &&
(rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)
) {
delete rendered[STREAM];
rendered[DYNAMICS] = [];
output.streams.add(stream);
// we don't need to store the rendered tree for streams
if (rendered[STREAM]) {
const stream = rendered[STREAM];
const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null];
if (
stream !== undefined &&
(rendered[KEYED][KEYED_COUNT] > 0 || deleteIds.length > 0 || reset)
) {
delete rendered[STREAM];
rendered[KEYED] = {
[KEYED_COUNT]: 0,
};
output.streams.add(stream);
}
}
}

Expand Down
Loading
Loading