Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ React or Vue wrappers.
prepared packages, mounts one retained PolyCSS graph, and exposes
caller-driven runtimes for morphs, controls, springs, animation, skinning,
and prepared playback.
- `createPolyMorphPreparedDomTarget` adopts a caller-owned retained graph as
source-ordered model, shape, and leaf targets. It tracks requested values for
sparse write deduplication, preserves element identity, invalidates writers
when destroyed, and leaves DOM teardown to the caller.
- A mount uses PolyCSS's public solid-triangle support check, so Firefox keeps
its border-triangle path while WebKit/Safari selects each leaf's prepared
polygon-sized atlas slice. Image paint comes only from loader-verified bytes:
Expand Down
5 changes: 5 additions & 0 deletions packages/morph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ The browser API is intentionally imperative:
`mounted.apply(sample.update)` succeeds;
- call `mounted.destroy()` at teardown.

Consumers that already own a retained DOM graph can adopt its source-ordered
model, shape, and leaf elements with `createPolyMorphPreparedDomTarget`.
The target owns write deduplication and invalidation, while the consumer
continues to own the elements and their teardown.

Morph owns no `requestAnimationFrame` loop, interval, or other scheduler. A
mounted model keeps the same leaf elements for its lifetime. Runtime updates do
not rebuild topology, add or remove leaves, construct image resources, or
Expand Down
81 changes: 81 additions & 0 deletions packages/morph/scripts/certify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,11 @@ async function writeConsumer(
import { createPolyOrthographicCamera } from "@layoutit/polycss";
import {
createPolyMorphDeformationRuntime,
createPolyMorphPreparedDomTarget,
mountPolyMorphModel,
type PolyMorphModel,
type PolyMorphPreparedDomTarget,
type PolyMorphPreparedElementTarget,
} from "@layoutit/polycss-morph";
import {
preparePolyMorphModel,
Expand All @@ -169,17 +172,31 @@ import {

declare const host: HTMLElement;
declare const model: PolyMorphModel;
declare const modelElement: HTMLElement;
declare const shapeElement: HTMLElement;
declare const leafElement: HTMLElement;

const camera = createPolyOrthographicCamera({ zoom: 1 });
const mounted = mountPolyMorphModel(host, model, { camera });
const runtime = createPolyMorphDeformationRuntime(model);
const preparedTarget: PolyMorphPreparedDomTarget =
createPolyMorphPreparedDomTarget({
model: {
element: modelElement,
writeTransform: () => true,
},
shapes: [{ element: shapeElement }],
leaves: [{ element: leafElement }],
});
const preparedLeaf: PolyMorphPreparedElementTarget = preparedTarget.leaves[0]!;
const options: PolyMorphPrepareOptions = {
configPath: "./source/prepare.json",
outputRoot: "./model/package",
};

void runtime.sample({ tick: 0 });
void mounted;
void preparedLeaf;
void preparePolyMorphModel(options);
`);
await writeFile(resolve(consumerRoot, "prepare-smoke.mjs"), `
Expand Down Expand Up @@ -264,6 +281,7 @@ const fetchImpl = async (input) => {

const {
createPolyMorphDeformationRuntime,
createPolyMorphPreparedDomTarget,
loadPolyMorphPackage,
mountPolyMorphModel,
} = await import("@layoutit/polycss-morph");
Expand Down Expand Up @@ -349,6 +367,67 @@ const fallbackDom = [...fallbackMounted.leafHandles.values()]
&& element.style.getPropertyValue("-webkit-mask-image").includes("blob:");
});

const adoptedHost = document.createElement("main");
const adoptedModel = document.createElement("div");
const adoptedShape = document.createElement("div");
const adoptedLeaf = document.createElement("s");
document.body.appendChild(adoptedHost);
adoptedHost.appendChild(adoptedModel);
adoptedModel.appendChild(adoptedShape);
adoptedShape.appendChild(adoptedLeaf);
let adoptedModelTransform;
let adoptedModelWrites = 0;
const preparedTarget = createPolyMorphPreparedDomTarget({
model: {
element: adoptedModel,
writeTransform(transform) {
if (adoptedModelTransform === transform) return false;
adoptedModelTransform = transform;
adoptedModel.style.transform = transform;
adoptedModelWrites += 1;
return true;
},
},
shapes: [{ element: adoptedShape }],
leaves: [{ element: adoptedLeaf }],
});
const adoptedIdentityWrite = preparedTarget.model.writeTransform("");
const adoptedIdentityRepeat = preparedTarget.model.writeTransform("");
const adoptedLeafWrite = preparedTarget.leaves[0].writeTransform("");
adoptedLeaf.style.transform = "scale(2)";
const adoptedLeafRepeat = preparedTarget.leaves[0].writeTransform("");
const adoptedVisibilityWrite = preparedTarget.leaves[0].writeVisibility(false);
const adoptedVisibilityRepeat = preparedTarget.leaves[0].writeVisibility(false);
const adoptedOpacityWrite = preparedTarget.leaves[0].writeOpacity(0.5);
const adoptedOpacityRepeat = preparedTarget.leaves[0].writeOpacity(0.5);
const adoptedImageWrite = preparedTarget.leaves[0].writeImagePositionY("-4px");
const adoptedImageRepeat = preparedTarget.leaves[0].writeImagePositionY("-4px");
preparedTarget.assertStableDomIdentity();
preparedTarget.destroy();
let destroyedTargetRejectsWrites = false;
try {
preparedTarget.leaves[0].writeVisibility(true);
} catch {
destroyedTargetRejectsWrites = true;
}
const preparedTargetCertified = adoptedIdentityWrite
&& !adoptedIdentityRepeat
&& adoptedModelWrites === 1
&& adoptedLeafWrite
&& !adoptedLeafRepeat
&& adoptedLeaf.style.transform === "scale(2)"
&& adoptedVisibilityWrite
&& !adoptedVisibilityRepeat
&& adoptedOpacityWrite
&& !adoptedOpacityRepeat
&& adoptedImageWrite
&& !adoptedImageRepeat
&& preparedTarget.destroyed
&& destroyedTargetRejectsWrites
&& adoptedModel.parentElement === adoptedHost
&& adoptedShape.parentElement === adoptedModel
&& adoptedLeaf.parentElement === adoptedShape;

if (
mounted.leafHandles.size !== loaded.model.render.leaves.length
|| frame.dirtyLeafIds.length === 0
Expand All @@ -363,6 +442,7 @@ if (
|| fallbackApplied.atlasRedraws !== 0
|| fallbackApplied.topologyConstructions !== 0
|| fallbackApplied.schedulerCallbacks !== 0
|| !preparedTargetCertified
) {
throw new Error("packed browser entry violated the retained-model contract");
}
Expand All @@ -378,6 +458,7 @@ console.log(JSON.stringify({
solidLeaves,
polygonSizedFallbacks,
canonicalDom,
preparedTargetCertified,
applied,
stats: mounted.stats,
fallback: {
Expand Down
7 changes: 7 additions & 0 deletions packages/morph/src/render/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { mountPolyMorphModel } from "./mount.js";
export { createPolyMorphPreparedDomTarget } from "./preparedDomTarget.js";
export { PolyMorphRenderError } from "./renderError.js";
export type {
PolyMorphApplyResult,
Expand All @@ -7,6 +8,12 @@ export type {
PolyMorphLeafUpdate,
PolyMorphMountedModel,
PolyMorphMountOptions,
PolyMorphPreparedDomTarget,
PolyMorphPreparedDomTargetInput,
PolyMorphPreparedElementTarget,
PolyMorphPreparedElementTargetInput,
PolyMorphPreparedModelTarget,
PolyMorphPreparedModelTargetInput,
PolyMorphRenderStats,
PolyMorphRetainedUpdate,
PolyMorphShapeUpdate,
Expand Down
149 changes: 149 additions & 0 deletions packages/morph/src/render/preparedDomTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
createPolyMorphPreparedDomTarget,
PolyMorphRenderError,
} from "./index.js";

function createGraph() {
const host = document.createElement("main");
const model = document.createElement("div");
const shape = document.createElement("div");
const leaf = document.createElement("u");
document.body.appendChild(host);
host.appendChild(model);
model.appendChild(shape);
shape.appendChild(leaf);
return { host, model, shape, leaf };
}

function createTarget(writeModelTransform?: (transform: string) => boolean) {
const graph = createGraph();
let modelTransform: string | undefined;
let modelWrites = 0;
const target = createPolyMorphPreparedDomTarget({
model: {
element: graph.model,
writeTransform(transform) {
if (writeModelTransform) return writeModelTransform(transform);
if (modelTransform === transform) return false;
modelTransform = transform;
graph.model.style.transform = transform;
modelWrites += 1;
return true;
},
},
shapes: [{ element: graph.shape }],
leaves: [{ element: graph.leaf }],
});
return { ...graph, target, get modelWrites() { return modelWrites; } };
}

describe("createPolyMorphPreparedDomTarget", () => {
beforeEach(() => {
document.body.replaceChildren();
});

it("writes through stable indexed targets and deduplicates requested values", () => {
const graph = createTarget();
const { target } = graph;

expect(Object.isFrozen(target.shapes)).toBe(true);
expect(Object.isFrozen(target.leaves)).toBe(true);
expect(target.shapes[0]!.element).toBe(graph.shape);
expect(target.leaves[0]!.element).toBe(graph.leaf);
expect(target.model.writeTransform("")).toBe(true);
expect(target.model.writeTransform("")).toBe(false);
expect(graph.modelWrites).toBe(1);

expect(target.shapes[0]!.writeTransform("")).toBe(true);
expect(target.shapes[0]!.writeTransform("")).toBe(false);
graph.shape.style.transform = "scale(2)";
expect(target.shapes[0]!.writeTransform("")).toBe(false);
expect(graph.shape.style.transform).toBe("scale(2)");

const transform = "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,2,3,4,1)";
expect(target.leaves[0]!.writeTransform(transform)).toBe(true);
expect(target.leaves[0]!.writeTransform(transform)).toBe(false);
expect(target.leaves[0]!.writeVisibility(false)).toBe(true);
expect(target.leaves[0]!.writeVisibility(false)).toBe(false);
expect(target.leaves[0]!.writeOpacity(0.5)).toBe(true);
expect(target.leaves[0]!.writeOpacity(0.5)).toBe(false);
expect(target.leaves[0]!.writeImagePositionY("-4px")).toBe(true);
expect(target.leaves[0]!.writeImagePositionY("-4px")).toBe(false);
expect(() => target.assertStableDomIdentity()).not.toThrow();
expect(graph.host.querySelectorAll("*")).toHaveLength(3);
});

it("rejects invalid target inputs and writer values", () => {
expect(() => createPolyMorphPreparedDomTarget(null as never))
.toThrowError(PolyMorphRenderError);
expect(() => createPolyMorphPreparedDomTarget({
model: {
element: document.createElement("div"),
writeTransform: () => true,
},
shapes: null as never,
leaves: [],
})).toThrowError(PolyMorphRenderError);
expect(() => createPolyMorphPreparedDomTarget({
model: {
element: document.createElement("div"),
writeTransform: () => true,
},
shapes: [{ element: {} as HTMLElement }],
leaves: [],
})).toThrowError(PolyMorphRenderError);
expect(() => createPolyMorphPreparedDomTarget({
model: {
element: document.createElement("div"),
writeTransform: null as never,
},
shapes: [],
leaves: [],
})).toThrowError(PolyMorphRenderError);

const graph = createTarget(() => "changed" as unknown as boolean);
expect(() => graph.target.model.writeTransform(""))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeTransform(null as never))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeVisibility("yes" as never))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeOpacity(Number.NaN))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeOpacity(-0.1))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeOpacity(1.1))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeImagePositionY(""))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.leaves[0]!.writeImagePositionY(null as never))
.toThrowError(PolyMorphRenderError);
});

it.each(["model", "shape", "leaf"] as const)(
"detects %s parent identity drift",
(kind) => {
const graph = createTarget();
const element = graph[kind];
document.body.appendChild(element);
expect(() => graph.target.assertStableDomIdentity())
.toThrowError(PolyMorphRenderError);
},
);

it("invalidates writers without removing caller-owned DOM", () => {
const graph = createTarget();
graph.target.destroy();
graph.target.destroy();

expect(graph.target.destroyed).toBe(true);
expect(graph.host.querySelectorAll("*")).toHaveLength(3);
expect(() => graph.target.assertStableDomIdentity())
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.model.writeTransform(""))
.toThrowError(PolyMorphRenderError);
expect(() => graph.target.shapes[0]!.writeVisibility(true))
.toThrowError(PolyMorphRenderError);
});
});
Loading
Loading