mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 09:28:21 +00:00
chore: prepare vite integration
This commit is contained in:
parent
8780b81b87
commit
b7a7558107
19 changed files with 508 additions and 143 deletions
10
assets/js/live_svelte/types.d.ts
vendored
10
assets/js/live_svelte/types.d.ts
vendored
|
|
@ -414,3 +414,13 @@ export declare function useEventReply<T = unknown, P extends object | void = obj
|
|||
options?: UseEventReplyOptions<T>
|
||||
): UseEventReplyReturn<T, P>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Virtual module type declaration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare module "virtual:live-svelte-components" {
|
||||
import type { Component } from "svelte";
|
||||
const components: Record<string, Component>;
|
||||
export default components;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,106 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
import { resolve, relative } from "node:path"
|
||||
import { readdirSync, existsSync } from "node:fs"
|
||||
|
||||
const VIRTUAL_MODULE_ID = "virtual:live-svelte-components"
|
||||
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID
|
||||
|
||||
/**
|
||||
* Returns the base directory from a glob pattern (everything before the first `*`),
|
||||
* with any trailing slash stripped.
|
||||
* @param {string} pattern - Glob pattern like `'./svelte/**\/*.svelte'`
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBaseDir(pattern) {
|
||||
const idx = pattern.indexOf("*")
|
||||
return idx === -1 ? pattern : pattern.slice(0, idx).replace(/\/$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the component name from an absolute file path relative to its base directory.
|
||||
* Strips the `.svelte` extension and normalizes backslashes to forward slashes.
|
||||
* @param {string} filePath - Absolute path to the `.svelte` file
|
||||
* @param {string} baseDir - Absolute base directory (resolved from pattern)
|
||||
* @returns {string} Component name, e.g. `'Counter'` or `'forms/ContactForm'`
|
||||
*/
|
||||
export function getComponentName(filePath, baseDir) {
|
||||
return relative(baseDir, filePath)
|
||||
.replace(/\.svelte$/, "")
|
||||
.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a directory and collects `.svelte` file paths.
|
||||
* @param {string} dir - Absolute path to the directory
|
||||
* @param {string[]} [results=[]] - Accumulator array
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function walkDir(dir, results = []) {
|
||||
if (!existsSync(dir)) return results
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = resolve(dir, entry.name)
|
||||
if (entry.isDirectory()) walkDir(full, results)
|
||||
else if (entry.name.endsWith(".svelte")) results.push(full)
|
||||
}
|
||||
} catch {
|
||||
/* ignore permission errors */
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates ESM virtual module source from a pre-built list of discovered files.
|
||||
* Pure function — no filesystem access. Exported for testability.
|
||||
* Uses JSON.stringify for all embedded string literals to safely handle paths
|
||||
* or component names that contain single quotes or other special characters.
|
||||
* @param {{ file: string, baseDir: string }[]} files
|
||||
* @returns {string} ESM source code string
|
||||
*/
|
||||
export function buildModuleCode(files) {
|
||||
if (files.length === 0) {
|
||||
return `export default {}\n`
|
||||
}
|
||||
|
||||
const imports = files
|
||||
.map(({ file }, i) => `import __c${i} from ${JSON.stringify(file.replace(/\\/g, "/"))}`)
|
||||
.join("\n")
|
||||
|
||||
const entries = files
|
||||
.map(({ file, baseDir }, i) => ` ${JSON.stringify(getComponentName(file, baseDir))}: __c${i}`)
|
||||
.join(",\n")
|
||||
|
||||
return `${imports}\nexport default {\n${entries}\n}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers all `.svelte` files for the given component path patterns and
|
||||
* delegates code generation to `buildModuleCode`.
|
||||
* @param {string[]} componentPaths - Glob patterns relative to Vite project root
|
||||
* @param {string} root - Absolute Vite project root directory
|
||||
* @returns {string} ESM source code string
|
||||
*/
|
||||
function generateVirtualModuleCode(componentPaths, root) {
|
||||
const allFiles = []
|
||||
for (const pattern of componentPaths) {
|
||||
const baseDir = resolve(root, getBaseDir(pattern))
|
||||
const files = walkDir(baseDir)
|
||||
for (const file of files) {
|
||||
allFiles.push({ file, baseDir })
|
||||
}
|
||||
}
|
||||
return buildModuleCode(allFiles)
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PluginOptions
|
||||
* @property {string} [path] - SSR render endpoint path (default: "/ssr_render")
|
||||
* @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js")
|
||||
* @property {string | string[]} [components] - Glob pattern(s) for Svelte component
|
||||
* auto-discovery via `virtual:live-svelte-components`.
|
||||
* Patterns are relative to the Vite project root (where vite.config.js lives).
|
||||
* Default: `['./svelte/**\/*.svelte']`
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -56,18 +153,60 @@ const jsonMiddleware = (req, res, next) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* LiveSvelte Vite plugin for SSR and hot reload support.
|
||||
* LiveSvelte Vite plugin for SSR, hot reload support, and component auto-discovery.
|
||||
*
|
||||
* NOTE: Unlike LiveVue, LiveSvelte's `render()` function returns a `{head, html, css}`
|
||||
* object (not a plain HTML string). This plugin serialises that result as JSON so the
|
||||
* Elixir `LiveSvelte.SSR.ViteJS` module can decode it with `Jason.decode!/1`.
|
||||
*
|
||||
* **Component auto-discovery**: Import `virtual:live-svelte-components` to get a
|
||||
* `Record<name, Component>` map of all discovered Svelte components. Pass the map
|
||||
* directly to `getHooks()` and `getRender()`.
|
||||
*
|
||||
* @param {PluginOptions} [opts]
|
||||
* @returns {import("vite").Plugin}
|
||||
*/
|
||||
function liveSveltePlugin(opts = {}) {
|
||||
const componentPaths = opts.components
|
||||
? Array.isArray(opts.components)
|
||||
? opts.components
|
||||
: [opts.components]
|
||||
: ["./svelte/**/*.svelte"]
|
||||
|
||||
let root = process.cwd()
|
||||
|
||||
/** @type {import("vite").ViteDevServer | null} */
|
||||
let viteServer = null
|
||||
|
||||
function invalidateVirtualModule() {
|
||||
if (viteServer) {
|
||||
const mod = viteServer.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID)
|
||||
if (mod) {
|
||||
viteServer.moduleGraph.invalidateModule(mod)
|
||||
viteServer.ws.send({ type: "full-reload" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "live-svelte",
|
||||
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_VIRTUAL_MODULE_ID
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
||||
return generateVirtualModuleCode(componentPaths, root)
|
||||
}
|
||||
},
|
||||
|
||||
handleHotUpdate({ file, modules, server, timestamp }) {
|
||||
if (file.match(/\.(heex|ex)$/)) {
|
||||
const invalidatedModules = new Set()
|
||||
|
|
@ -100,14 +239,35 @@ function liveSveltePlugin(opts = {}) {
|
|||
return []
|
||||
}
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
viteServer = server
|
||||
|
||||
process.stdin.on("close", () => process.exit(0))
|
||||
process.stdin.resume()
|
||||
|
||||
const path = opts.path || "/ssr_render"
|
||||
// Watch component directories for new/deleted .svelte files so the
|
||||
// virtual:live-svelte-components module stays up to date.
|
||||
const baseDirs = componentPaths.map(p => resolve(root, getBaseDir(p)))
|
||||
for (const dir of baseDirs) {
|
||||
if (existsSync(dir)) server.watcher.add(dir)
|
||||
}
|
||||
|
||||
server.watcher.on("add", filePath => {
|
||||
if (filePath.endsWith(".svelte") && baseDirs.some(dir => filePath.startsWith(dir))) {
|
||||
invalidateVirtualModule()
|
||||
}
|
||||
})
|
||||
server.watcher.on("unlink", filePath => {
|
||||
if (filePath.endsWith(".svelte") && baseDirs.some(dir => filePath.startsWith(dir))) {
|
||||
invalidateVirtualModule()
|
||||
}
|
||||
})
|
||||
|
||||
const ssrPath = opts.path || "/ssr_render"
|
||||
const entrypoint = opts.entrypoint || "./js/server.js"
|
||||
server.middlewares.use(function liveSvelteMiddleware(req, res, next) {
|
||||
if (req.method == "POST" && req.url?.split("?", 1)[0] === path) {
|
||||
if (req.method == "POST" && req.url?.split("?", 1)[0] === ssrPath) {
|
||||
jsonMiddleware(req, res, async () => {
|
||||
try {
|
||||
const render = (await server.ssrLoadModule(entrypoint)).render
|
||||
|
|
|
|||
97
assets/js/live_svelte/vite_plugin.test.ts
Normal file
97
assets/js/live_svelte/vite_plugin.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
// @ts-expect-error - vite_plugin.js is plain JS without type declarations
|
||||
import { getBaseDir, getComponentName, buildModuleCode } from "./vite_plugin.js";
|
||||
import path from "node:path";
|
||||
|
||||
describe("getBaseDir", () => {
|
||||
it("strips wildcard and trailing slash from glob pattern", () => {
|
||||
expect(getBaseDir("./svelte/**/*.svelte")).toBe("./svelte");
|
||||
});
|
||||
|
||||
it("returns pattern unchanged when no wildcard present", () => {
|
||||
expect(getBaseDir("./svelte")).toBe("./svelte");
|
||||
});
|
||||
|
||||
it("handles parent-directory relative patterns", () => {
|
||||
expect(getBaseDir("../svelte/**/*.svelte")).toBe("../svelte");
|
||||
});
|
||||
|
||||
it("handles single-level glob pattern", () => {
|
||||
expect(getBaseDir("./svelte/*.svelte")).toBe("./svelte");
|
||||
});
|
||||
|
||||
it("handles pattern with trailing slash before wildcard", () => {
|
||||
expect(getBaseDir("./components/**/*.svelte")).toBe("./components");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getComponentName", () => {
|
||||
const base = path.resolve("/project/assets/svelte");
|
||||
|
||||
it("returns filename without extension for top-level components", () => {
|
||||
const file = path.resolve("/project/assets/svelte/Counter.svelte");
|
||||
expect(getComponentName(file, base)).toBe("Counter");
|
||||
});
|
||||
|
||||
it("preserves subdirectory prefix for nested components", () => {
|
||||
const file = path.resolve("/project/assets/svelte/forms/ContactForm.svelte");
|
||||
expect(getComponentName(file, base)).toBe("forms/ContactForm");
|
||||
});
|
||||
|
||||
it("handles deeply nested components", () => {
|
||||
const file = path.resolve("/project/assets/svelte/ui/forms/Input.svelte");
|
||||
expect(getComponentName(file, base)).toBe("ui/forms/Input");
|
||||
});
|
||||
|
||||
it("normalizes backslashes to forward slashes", () => {
|
||||
const file = path.resolve("/project/assets/svelte/forms/Form.svelte");
|
||||
const result = getComponentName(file, base);
|
||||
expect(result).not.toContain("\\");
|
||||
expect(result).toBe("forms/Form");
|
||||
});
|
||||
|
||||
it("strips .svelte extension correctly", () => {
|
||||
const file = path.resolve("/project/assets/svelte/MyComponent.svelte");
|
||||
const result = getComponentName(file, base);
|
||||
expect(result).toBe("MyComponent");
|
||||
expect(result).not.toContain(".svelte");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModuleCode", () => {
|
||||
it("returns an empty default export for an empty file list", () => {
|
||||
expect(buildModuleCode([])).toBe("export default {}\n");
|
||||
});
|
||||
|
||||
it("generates a single import and export entry for one component", () => {
|
||||
const base = path.resolve("/project/assets/svelte");
|
||||
const file = path.resolve("/project/assets/svelte/Counter.svelte");
|
||||
const code = buildModuleCode([{ file, baseDir: base }]);
|
||||
expect(code).toMatch(/^import __c0 from /m);
|
||||
expect(code).toContain("Counter.svelte");
|
||||
expect(code).toContain('"Counter": __c0');
|
||||
expect(code).toContain("export default {");
|
||||
});
|
||||
|
||||
it("generates sequential __c0, __c1 imports for multiple components", () => {
|
||||
const base = path.resolve("/project/assets/svelte");
|
||||
const files = [
|
||||
{ file: path.resolve("/project/assets/svelte/Counter.svelte"), baseDir: base },
|
||||
{ file: path.resolve("/project/assets/svelte/forms/Form.svelte"), baseDir: base },
|
||||
];
|
||||
const code = buildModuleCode(files);
|
||||
expect(code).toMatch(/^import __c0 from /m);
|
||||
expect(code).toMatch(/^import __c1 from /m);
|
||||
expect(code).toContain('"Counter": __c0');
|
||||
expect(code).toContain('"forms/Form": __c1');
|
||||
});
|
||||
|
||||
it("uses double-quoted string literals (JSON.stringify) for safe path embedding", () => {
|
||||
const base = path.resolve("/project/assets/svelte");
|
||||
const file = path.resolve("/project/assets/svelte/Counter.svelte");
|
||||
const code = buildModuleCode([{ file, baseDir: base }]);
|
||||
// JSON.stringify wraps in double quotes — safe for paths containing single quotes or backslashes
|
||||
expect(code).toMatch(/import __c0 from ".*Counter\.svelte"/);
|
||||
expect(code).toMatch(/"Counter": __c0/);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,8 @@ import Config
|
|||
config :live_svelte,
|
||||
ssr_module: LiveSvelte.SSR.NodeJS,
|
||||
ssr: true
|
||||
# json_library defaults to LiveSvelte.JSON (native Erlang :json module)
|
||||
|
||||
# json_library defaults to LiveSvelte.JSON (native Erlang :json module)
|
||||
|
||||
# if Mix.env() == :dev do
|
||||
# esbuild = fn args ->
|
||||
|
|
|
|||
|
|
@ -9,17 +9,7 @@ import topbar from "../vendor/topbar"
|
|||
// TODO(Epic 9): remove createLiveJsonHooks once live_json dependency is removed
|
||||
import {createLiveJsonHooks} from "live_json"
|
||||
import {getHooks} from "live_svelte"
|
||||
|
||||
// import.meta.glob returns Record<path, module> (e.g. {"../svelte/Counter.svelte": {default: ...}}).
|
||||
// getHooks expects Record<name, Component> (e.g. {"Counter": Component}).
|
||||
// Transform: strip path prefix and .svelte extension to get the component name.
|
||||
const rawComponents = import.meta.glob('../svelte/**/*.svelte', { eager: true })
|
||||
const Components = Object.fromEntries(
|
||||
Object.entries(rawComponents).map(([path, mod]) => [
|
||||
path.replace('../svelte/', '').replace('.svelte', ''),
|
||||
mod.default,
|
||||
])
|
||||
)
|
||||
import Components from "virtual:live-svelte-components"
|
||||
|
||||
function formatPayload(str) {
|
||||
if (!str || str === "—") return "—"
|
||||
|
|
|
|||
|
|
@ -3,16 +3,6 @@
|
|||
// syntax used by server.js. Configured via vite.config.js:
|
||||
// liveSveltePlugin({ entrypoint: './js/server.vite.js' })
|
||||
import { getRender } from "live_svelte"
|
||||
|
||||
// import.meta.glob returns Record<path, module> (e.g. {"../svelte/Counter.svelte": {default: ...}}).
|
||||
// getRender/getHooks expect Record<name, Component> (e.g. {"Counter": Component}).
|
||||
// Transform: strip path prefix and .svelte extension to get the component name.
|
||||
const rawComponents = import.meta.glob("../svelte/**/*.svelte", { eager: true })
|
||||
const Components = Object.fromEntries(
|
||||
Object.entries(rawComponents).map(([path, mod]) => [
|
||||
path.replace("../svelte/", "").replace(".svelte", ""),
|
||||
mod.default,
|
||||
])
|
||||
)
|
||||
import Components from "virtual:live-svelte-components"
|
||||
|
||||
export const render = getRender(Components)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
let { color, index } = $props();
|
||||
console.log(color);
|
||||
let {color, index} = $props()
|
||||
console.log(color)
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
class:border-red-500={color === "red"}
|
||||
class:bg-red-500={color === "red"}
|
||||
class:text-white={color === "red"}
|
||||
class="flex flex-col justify-center items-center p-4 w-100 border-1 rounded-md shadow-lg"
|
||||
class="flex flex-col justify-center items-center p-4 w-100 border rounded-md shadow-lg"
|
||||
>
|
||||
<div class="font-bold p-1 text-lg">{index}</div>
|
||||
<div class="font-bold p-1 text-lg">Svelte component</div>
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@
|
|||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Named slots with dynamic content</span>
|
||||
</li>
|
||||
<%!-- <li>
|
||||
<a href={~p"/live-slots-nested"} class="link link-primary">
|
||||
Nested Slots
|
||||
</a>
|
||||
<span class="text-base-content/50 text-sm">- Named slots with dynamic content</span>
|
||||
</li> --%>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,51 +2,52 @@ defmodule ExampleWeb.Router do
|
|||
use ExampleWeb, :router
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, {ExampleWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug(:accepts, ["html"])
|
||||
plug(:fetch_session)
|
||||
plug(:fetch_live_flash)
|
||||
plug(:put_root_layout, {ExampleWeb.Layouts, :root})
|
||||
plug(:protect_from_forgery)
|
||||
plug(:put_secure_browser_headers)
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
plug(:accepts, ["json"])
|
||||
end
|
||||
|
||||
scope "/", ExampleWeb do
|
||||
pipe_through :browser
|
||||
pipe_through(:browser)
|
||||
|
||||
get "/", PageController, :home
|
||||
get("/", PageController, :home)
|
||||
# same order as in app.html.heex:
|
||||
get "/hello-world", PageController, :hello_world
|
||||
get "/lodash", PageController, :lodash
|
||||
live "/live-struct", LiveStruct
|
||||
live "/live-simple-counter", LiveSimpleCounter
|
||||
live "/live-lights", LiveLights
|
||||
live "/live-sigil", LiveSigil
|
||||
get "/plus-minus-svelte", PageController, :plus_minus_svelte
|
||||
live "/live-plus-minus", LivePlusMinus
|
||||
live "/live-plus-minus-hybrid", LivePlusMinusHybrid
|
||||
live "/live-static-color", LiveStaticColor
|
||||
live "/live-log-list", LiveLogList
|
||||
live "/live-breaking-news", LiveBreakingNews
|
||||
live "/live-chat", LiveChat
|
||||
live "/live-json", LiveJson
|
||||
live "/live-props-diff", LivePropsDiff
|
||||
live "/streams", Streams
|
||||
live "/live-id-list-diff", LiveIdListDiff
|
||||
live "/live-slots-simple", LiveSlotsSimple
|
||||
live "/live-slots-dynamic", LiveSlotsDynamic
|
||||
live "/live-client-side-loading", LiveClientSideLoading
|
||||
get("/hello-world", PageController, :hello_world)
|
||||
get("/lodash", PageController, :lodash)
|
||||
live("/live-struct", LiveStruct)
|
||||
live("/live-simple-counter", LiveSimpleCounter)
|
||||
live("/live-lights", LiveLights)
|
||||
live("/live-sigil", LiveSigil)
|
||||
get("/plus-minus-svelte", PageController, :plus_minus_svelte)
|
||||
live("/live-plus-minus", LivePlusMinus)
|
||||
live("/live-plus-minus-hybrid", LivePlusMinusHybrid)
|
||||
live("/live-static-color", LiveStaticColor)
|
||||
live("/live-log-list", LiveLogList)
|
||||
live("/live-breaking-news", LiveBreakingNews)
|
||||
live("/live-chat", LiveChat)
|
||||
live("/live-json", LiveJson)
|
||||
live("/live-props-diff", LivePropsDiff)
|
||||
live("/streams", Streams)
|
||||
live("/live-id-list-diff", LiveIdListDiff)
|
||||
live("/live-slots-simple", LiveSlotsSimple)
|
||||
live("/live-slots-dynamic", LiveSlotsDynamic)
|
||||
live("/live-slots-nested", LiveSlotsNested)
|
||||
live("/live-client-side-loading", LiveClientSideLoading)
|
||||
# Ecto Examples
|
||||
live "/live-notes-otp", LiveNotesOtp
|
||||
live "/live-form", LiveForm
|
||||
live "/live-upload", LiveUpload
|
||||
live "/live-event-reply", LiveEventReply
|
||||
live "/live-navigation", LiveNavigation
|
||||
live "/live-navigation/:page", LiveNavigation
|
||||
live "/live-composition", LiveComposition
|
||||
live("/live-notes-otp", LiveNotesOtp)
|
||||
live("/live-form", LiveForm)
|
||||
live("/live-upload", LiveUpload)
|
||||
live("/live-event-reply", LiveEventReply)
|
||||
live("/live-navigation", LiveNavigation)
|
||||
live("/live-navigation/:page", LiveNavigation)
|
||||
live("/live-composition", LiveComposition)
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
|
@ -64,10 +65,10 @@ defmodule ExampleWeb.Router do
|
|||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
pipe_through(:browser)
|
||||
|
||||
live_dashboard "/dashboard", metrics: ExampleWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
live_dashboard("/dashboard", metrics: ExampleWeb.Telemetry)
|
||||
forward("/mailbox", Plug.Swoosh.MailboxPreview)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ defmodule LiveSvelte do
|
|||
|
||||
attr :diff, :boolean,
|
||||
default: true,
|
||||
doc: "When true (and config enable_props_diff is true), only changed props are sent on update. Set to false to always send full props."
|
||||
doc:
|
||||
"When true (and config enable_props_diff is true), only changed props are sent on update. Set to false to always send full props."
|
||||
|
||||
slot :inner_block, doc: "Inner block of the Svelte component"
|
||||
|
||||
|
|
@ -79,7 +80,8 @@ defmodule LiveSvelte do
|
|||
ssr_active = Application.get_env(:live_svelte, :ssr, true)
|
||||
use_diff = diff_enabled?(assigns)
|
||||
|
||||
svelte_id = assigns.id || key_based_id(assigns.name, assigns.key, assigns.props, assigns.__changed__)
|
||||
svelte_id =
|
||||
assigns.id || key_based_id(assigns.name, assigns.key, assigns.props, assigns.__changed__)
|
||||
|
||||
# Snapshot previous props BEFORE props_for_payload/5 updates the process dict.
|
||||
# Used for JSON Patch diff computation (Tier 2 + 3).
|
||||
|
|
@ -148,31 +150,31 @@ defmodule LiveSvelte do
|
|||
<script>
|
||||
<%= raw(@ssr_render["head"]) %>
|
||||
</script>
|
||||
<div
|
||||
id={@svelte_id}
|
||||
data-name={@name}
|
||||
data-props={json(@props_to_send)}
|
||||
data-props-diff={json(@props_diff)}
|
||||
data-streams-diff={json(@streams_diff)}
|
||||
data-use-diff={to_string(@use_diff)}
|
||||
data-ssr={@ssr_render != nil}
|
||||
data-live-json={
|
||||
if @init, do: json(@live_json_props), else: @live_json_props |> Map.keys() |> json()
|
||||
}
|
||||
data-slots={@slots |> Slots.base_encode_64() |> json}
|
||||
phx-hook="SvelteHook"
|
||||
phx-update="ignore"
|
||||
class={@class}
|
||||
>
|
||||
<div id={"#{@svelte_id}-target"} data-svelte-target>
|
||||
<%= raw(@ssr_render["head"]) %>
|
||||
<style>
|
||||
<%= raw(@ssr_render["css"]["code"]) %>
|
||||
</style>
|
||||
<%= raw(@ssr_render["html"]) %>
|
||||
<%= render_slot(@loading) %>
|
||||
<div
|
||||
id={@svelte_id}
|
||||
data-name={@name}
|
||||
data-props={json(@props_to_send)}
|
||||
data-props-diff={json(@props_diff)}
|
||||
data-streams-diff={json(@streams_diff)}
|
||||
data-use-diff={to_string(@use_diff)}
|
||||
data-ssr={@ssr_render != nil}
|
||||
data-live-json={
|
||||
if @init, do: json(@live_json_props), else: @live_json_props |> Map.keys() |> json()
|
||||
}
|
||||
data-slots={@slots |> Slots.base_encode_64() |> json}
|
||||
phx-hook="SvelteHook"
|
||||
phx-update="ignore"
|
||||
class={@class}
|
||||
>
|
||||
<div id={"#{@svelte_id}-target"} data-svelte-target>
|
||||
<%= raw(@ssr_render["head"]) %>
|
||||
<style>
|
||||
<%= raw(@ssr_render["css"]["code"]) %>
|
||||
</style>
|
||||
<%= raw(@ssr_render["html"]) %>
|
||||
<%= render_slot(@loading) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.live_json>
|
||||
"""
|
||||
end
|
||||
|
|
@ -196,9 +198,14 @@ defmodule LiveSvelte do
|
|||
props = Map.get(assigns, :props, %{})
|
||||
|
||||
cond do
|
||||
init or dead or not use_diff -> props
|
||||
is_map(assigns.__changed__[:props]) -> props_changed_only(props, assigns.__changed__[:props])
|
||||
true -> props
|
||||
init or dead or not use_diff ->
|
||||
props
|
||||
|
||||
is_map(assigns.__changed__[:props]) ->
|
||||
props_changed_only(props, assigns.__changed__[:props])
|
||||
|
||||
true ->
|
||||
props
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -445,6 +452,7 @@ defmodule LiveSvelte do
|
|||
nil ->
|
||||
maybe_reset_id_counters_for_update(changed)
|
||||
counter_id(name)
|
||||
|
||||
identity ->
|
||||
"#{name}-#{identity}"
|
||||
end
|
||||
|
|
@ -490,7 +498,11 @@ defmodule LiveSvelte do
|
|||
|
||||
# Simple counter for standalone (non-loop) components that lack identity props.
|
||||
defp counter_id(name) do
|
||||
Process.put(:live_svelte_counter_names, Enum.uniq([name | Process.get(:live_svelte_counter_names, [])]))
|
||||
Process.put(
|
||||
:live_svelte_counter_names,
|
||||
Enum.uniq([name | Process.get(:live_svelte_counter_names, [])])
|
||||
)
|
||||
|
||||
Process.put(:live_svelte_total_counter, Process.get(:live_svelte_total_counter, 0) + 1)
|
||||
key = {:live_svelte_counter, name}
|
||||
count = Process.get(key, 0)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
|
|||
defp maybe_enhance_error(%{value: %Ecto.Association.NotLoaded{}} = error) do
|
||||
Map.update!(error, :description, fn description ->
|
||||
[first | rest] = String.split(description, "\n\n")
|
||||
addition = "\n\nEncode form with LiveSvelte.Encoder.encode(form, nilify_not_loaded: true) to avoid."
|
||||
|
||||
addition =
|
||||
"\n\nEncode form with LiveSvelte.Encoder.encode(form, nilify_not_loaded: true) to avoid."
|
||||
|
||||
Enum.join([first | [addition | rest]], "\n\n")
|
||||
end)
|
||||
end
|
||||
|
|
@ -105,37 +108,56 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
|
|||
@relations [:embed, :assoc]
|
||||
|
||||
defp collect_changeset_values(%Ecto.Changeset{} = source, opts) do
|
||||
data = Map.new(source.types, fn {field, type} -> {field, get_field_value(source, field, type, opts)} end)
|
||||
data =
|
||||
Map.new(source.types, fn {field, type} ->
|
||||
{field, get_field_value(source, field, type, opts)}
|
||||
end)
|
||||
|
||||
result = if is_struct(source.data), do: Map.merge(source.data, data), else: data
|
||||
Map.delete(result, :__meta__)
|
||||
end
|
||||
|
||||
defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts) when tag in @relations do
|
||||
defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts)
|
||||
when tag in @relations do
|
||||
case Map.fetch(source.changes, field) do
|
||||
{:ok, nil} -> nil
|
||||
{:ok, %Ecto.Changeset{} = changeset} -> collect_changeset_values(changeset, opts)
|
||||
{:ok, nil} ->
|
||||
nil
|
||||
|
||||
{:ok, %Ecto.Changeset{} = changeset} ->
|
||||
collect_changeset_values(changeset, opts)
|
||||
|
||||
:error ->
|
||||
case Map.fetch!(source.data, field) do
|
||||
%Ecto.Association.NotLoaded{} = not_loaded ->
|
||||
if opts[:nilify_not_loaded], do: nil, else: not_loaded
|
||||
%{__meta__: _} = value -> Map.delete(value, :__meta__)
|
||||
value -> value
|
||||
|
||||
%{__meta__: _} = value ->
|
||||
Map.delete(value, :__meta__)
|
||||
|
||||
value ->
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts) when tag in @relations do
|
||||
defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts)
|
||||
when tag in @relations do
|
||||
case Map.fetch(source.changes, field) do
|
||||
{:ok, changesets} ->
|
||||
changesets
|
||||
|> Enum.filter(&(&1.params != nil))
|
||||
|> Enum.map(&collect_changeset_values(&1, opts))
|
||||
|
||||
:error ->
|
||||
case Map.fetch!(source.data, field) do
|
||||
%Ecto.Association.NotLoaded{} = not_loaded ->
|
||||
if opts[:nilify_not_loaded], do: nil, else: not_loaded
|
||||
[%{__meta__: _} | _] = value -> Enum.map(value, &Map.delete(&1, :__meta__))
|
||||
value -> value
|
||||
|
||||
[%{__meta__: _} | _] = value ->
|
||||
Enum.map(value, &Map.delete(&1, :__meta__))
|
||||
|
||||
value ->
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -164,6 +186,7 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
|
|||
if Code.ensure_loaded?(Ecto.Changeset) do
|
||||
defp collect_changeset_errors(%Ecto.Changeset{} = changeset) do
|
||||
errors = translate_errors(changeset.errors)
|
||||
|
||||
Enum.reduce(changeset.changes, errors, fn {field, value}, acc ->
|
||||
case Map.get(changeset.types, field) do
|
||||
{tag, %{cardinality: :one}} when tag in @relations ->
|
||||
|
|
@ -178,9 +201,11 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
|
|||
embed_errors = collect_changeset_errors(embed_changeset)
|
||||
if embed_errors == %{}, do: nil, else: embed_errors
|
||||
end)
|
||||
|
||||
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
|
||||
|
||||
_ -> acc
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
@ -240,6 +265,7 @@ if Code.ensure_loaded?(Ecto.Changeset) do
|
|||
embed_errors = changeset_errors_to_map(embed_cs)
|
||||
if embed_errors == %{}, do: nil, else: embed_errors
|
||||
end)
|
||||
|
||||
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
|
||||
|
||||
{:assoc, %{cardinality: :one}} when is_struct(value, Ecto.Changeset) ->
|
||||
|
|
@ -254,6 +280,7 @@ if Code.ensure_loaded?(Ecto.Changeset) do
|
|||
assoc_errors = changeset_errors_to_map(assoc_cs)
|
||||
if assoc_errors == %{}, do: nil, else: assoc_errors
|
||||
end)
|
||||
|
||||
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
|
||||
|
||||
_ ->
|
||||
|
|
@ -270,7 +297,11 @@ if Code.ensure_loaded?(Ecto.Changeset) do
|
|||
|
||||
defp error_tuple_to_message({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", value |> List.wrap() |> Enum.map_join(", ", &to_string/1))
|
||||
String.replace(
|
||||
acc,
|
||||
"%{#{key}}",
|
||||
value |> List.wrap() |> Enum.map_join(", ", &to_string/1)
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -344,6 +375,7 @@ defimpl LiveSvelte.Encoder, for: Any do
|
|||
# (matches @derive LiveSvelte.Encoder default; __meta__ stripped for Ecto schemas).
|
||||
def encode(%{__struct__: _module} = struct, opts) do
|
||||
keys = Map.keys(struct) -- [:__struct__, :__meta__]
|
||||
|
||||
struct
|
||||
|> Map.take(keys)
|
||||
|> LiveSvelte.Encoder.encode(opts)
|
||||
|
|
@ -357,18 +389,22 @@ defimpl LiveSvelte.Encoder, for: Any do
|
|||
cond do
|
||||
only = Keyword.get(opts, :only) ->
|
||||
case only -- fields do
|
||||
[] -> only
|
||||
[] ->
|
||||
only
|
||||
|
||||
error_keys ->
|
||||
raise ArgumentError,
|
||||
":only specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
|
||||
":only specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
|
||||
end
|
||||
|
||||
except = Keyword.get(opts, :except) ->
|
||||
case except -- fields do
|
||||
[] -> fields -- [:__struct__ | except]
|
||||
[] ->
|
||||
fields -- [:__struct__ | except]
|
||||
|
||||
error_keys ->
|
||||
raise ArgumentError,
|
||||
":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
|
||||
":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
|
||||
end
|
||||
|
||||
true ->
|
||||
|
|
|
|||
|
|
@ -81,10 +81,12 @@ defmodule LiveSvelte.Test do
|
|||
|
||||
defp decode_handlers(nil), do: %{}
|
||||
defp decode_handlers(""), do: %{}
|
||||
defp decode_handlers(_str), do: %{} # LiveSvelte does not emit data-handlers yet
|
||||
# LiveSvelte does not emit data-handlers yet
|
||||
defp decode_handlers(_str), do: %{}
|
||||
|
||||
defp decode_slots(nil), do: %{}
|
||||
defp decode_slots(""), do: %{}
|
||||
|
||||
defp decode_slots(str) do
|
||||
str
|
||||
|> Jason.decode!()
|
||||
|
|
@ -108,16 +110,20 @@ defmodule LiveSvelte.Test do
|
|||
Enum.reduce(opts, components_tree, fn
|
||||
{:id, id}, result ->
|
||||
filtered = Enum.filter(result, &(attr_from_tree(&1, "id") == id))
|
||||
|
||||
if filtered == [] do
|
||||
raise "No Svelte component found with id=\"#{id}\". Available: #{available}"
|
||||
end
|
||||
|
||||
filtered
|
||||
|
||||
{:name, name}, result ->
|
||||
filtered = Enum.filter(result, &(attr_from_tree(&1, "data-name") == name))
|
||||
|
||||
if filtered == [] do
|
||||
raise "No Svelte component found with name=\"#{name}\". Available: #{available}"
|
||||
end
|
||||
|
||||
filtered
|
||||
|
||||
{key, _}, _ ->
|
||||
|
|
|
|||
|
|
@ -31,14 +31,22 @@ defmodule LiveSvelte.Reload do
|
|||
assigns =
|
||||
assigns
|
||||
|> assign(:vite_host, vite_host)
|
||||
|> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path))
|
||||
|> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path))
|
||||
|> assign(
|
||||
:stylesheets,
|
||||
for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path)
|
||||
)
|
||||
|> assign(
|
||||
:javascripts,
|
||||
for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path)
|
||||
)
|
||||
|
||||
~H"""
|
||||
<%= if @vite_host do %>
|
||||
<script type="module" src={"#{@vite_host}/@vite/client"}></script>
|
||||
<script type="module" src={"#{@vite_host}/@vite/client"}>
|
||||
</script>
|
||||
<link :for={path <- @stylesheets} rel="stylesheet" href={"#{@vite_host}#{path}"} />
|
||||
<script :for={path <- @javascripts} type="module" src={"#{@vite_host}#{path}"}></script>
|
||||
<script :for={path <- @javascripts} type="module" src={"#{@vite_host}#{path}"}>
|
||||
</script>
|
||||
<% else %>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ defmodule LiveSvelte.AutoIdTest do
|
|||
{:live_svelte_prev_props, _} = k -> Process.delete(k)
|
||||
_ -> :ok
|
||||
end)
|
||||
|
||||
Process.delete(:live_svelte_counter_names)
|
||||
Process.delete(:live_svelte_total_counter)
|
||||
Process.delete(:live_svelte_expected_total)
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ defmodule LiveSvelte.EncoderTest do
|
|||
use Ecto.Schema
|
||||
|
||||
schema "users" do
|
||||
field :name, :string
|
||||
field :email, :string
|
||||
field(:name, :string)
|
||||
field(:email, :string)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -134,7 +134,9 @@ defmodule LiveSvelte.EncoderTest do
|
|||
|> Ecto.Changeset.apply_action(:validate)
|
||||
|
||||
case cs do
|
||||
{:ok, _} -> flunk("expected invalid changeset")
|
||||
{:ok, _} ->
|
||||
flunk("expected invalid changeset")
|
||||
|
||||
{:error, changeset} ->
|
||||
encoded = Encoder.encode(changeset, [])
|
||||
assert encoded.valid? == false
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ defmodule LiveSvelte.TestTest do
|
|||
"data-ssr": "false",
|
||||
"data-slots": ~s({"default":"PHA+c2xvdDwvcD4="})
|
||||
]
|
||||
|
||||
merged = Keyword.merge(default, attrs)
|
||||
|
||||
attr_str =
|
||||
merged
|
||||
|> Enum.map(fn {k, v} -> ~s(#{k}='#{v}') end)
|
||||
|> Enum.join(" ")
|
||||
|
||||
"""
|
||||
<div #{attr_str} phx-hook="SvelteHook" phx-update="ignore">
|
||||
<div id="Counter-1-target"></div>
|
||||
|
|
@ -57,6 +60,7 @@ defmodule LiveSvelte.TestTest do
|
|||
<div id="A-1" data-name="A" data-props='{}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
|
||||
<div id="B-1" data-name="B" data-props='{"x":1}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
|
||||
"""
|
||||
|
||||
svelte = Test.get_svelte(html, name: "B")
|
||||
assert svelte.name == "B"
|
||||
assert svelte.props == %{"x" => 1}
|
||||
|
|
@ -64,6 +68,7 @@ defmodule LiveSvelte.TestTest do
|
|||
|
||||
test "raises when name does not match" do
|
||||
html = svelte_root_html()
|
||||
|
||||
assert_raise RuntimeError, ~r/No Svelte component found with name="NotFound"/, fn ->
|
||||
Test.get_svelte(html, name: "NotFound")
|
||||
end
|
||||
|
|
@ -77,6 +82,7 @@ defmodule LiveSvelte.TestTest do
|
|||
<div id="First" data-name="X" data-props='{}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
|
||||
<div id="Second" data-name="Y" data-props='{"y":2}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
|
||||
"""
|
||||
|
||||
svelte = Test.get_svelte(html, id: "Second")
|
||||
assert svelte.id == "Second"
|
||||
assert svelte.props == %{"y" => 2}
|
||||
|
|
@ -84,6 +90,7 @@ defmodule LiveSvelte.TestTest do
|
|||
|
||||
test "raises when id does not match" do
|
||||
html = svelte_root_html()
|
||||
|
||||
assert_raise RuntimeError, ~r/No Svelte component found with id="no-such"/, fn ->
|
||||
Test.get_svelte(html, id: "no-such")
|
||||
end
|
||||
|
|
@ -93,6 +100,7 @@ defmodule LiveSvelte.TestTest do
|
|||
describe "get_svelte from HTML with no Svelte components" do
|
||||
test "raises when no Svelte root present" do
|
||||
html = "<div>plain</div>"
|
||||
|
||||
assert_raise RuntimeError, ~r/No Svelte components found/, fn ->
|
||||
Test.get_svelte(html)
|
||||
end
|
||||
|
|
@ -102,12 +110,17 @@ defmodule LiveSvelte.TestTest do
|
|||
describe "get_svelte from LiveView" do
|
||||
test "extracts Svelte component from rendered LiveView component" do
|
||||
html =
|
||||
Phoenix.LiveViewTest.__render_component__(nil, &LiveSvelte.svelte/1, %{
|
||||
name: "TestComponent",
|
||||
props: %{value: 42},
|
||||
ssr: false,
|
||||
socket: nil
|
||||
}, [])
|
||||
Phoenix.LiveViewTest.__render_component__(
|
||||
nil,
|
||||
&LiveSvelte.svelte/1,
|
||||
%{
|
||||
name: "TestComponent",
|
||||
props: %{value: 42},
|
||||
ssr: false,
|
||||
socket: nil
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
svelte = Test.get_svelte(html)
|
||||
assert svelte.name == "TestComponent"
|
||||
|
|
@ -118,7 +131,12 @@ defmodule LiveSvelte.TestTest do
|
|||
|
||||
test "get_svelte(html, name: ...) when HTML from render_component" do
|
||||
html =
|
||||
Phoenix.LiveViewTest.__render_component__(nil, &LiveSvelte.svelte/1, %{name: "Demo", props: %{}, ssr: false, socket: nil}, [])
|
||||
Phoenix.LiveViewTest.__render_component__(
|
||||
nil,
|
||||
&LiveSvelte.svelte/1,
|
||||
%{name: "Demo", props: %{}, ssr: false, socket: nil},
|
||||
[]
|
||||
)
|
||||
|
||||
svelte = Test.get_svelte(html, name: "Demo")
|
||||
assert svelte.name == "Demo"
|
||||
|
|
@ -128,6 +146,7 @@ defmodule LiveSvelte.TestTest do
|
|||
describe "invalid option" do
|
||||
test "raises on unknown option" do
|
||||
html = svelte_root_html()
|
||||
|
||||
assert_raise ArgumentError, ~r/invalid keyword option/, fn ->
|
||||
Test.get_svelte(html, foo: "bar")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ defmodule LiveSvelte.PropsDiffTest do
|
|||
encoded
|
||||
|> String.replace(""", "\"")
|
||||
|> String.replace("'", "'")
|
||||
|
||||
# Use Erlang :json (same family as default LiveSvelte.JSON encoder)
|
||||
:json.decode(unescaped)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
|
@ -86,11 +88,14 @@ defmodule LiveSvelte.PropsDiffTest do
|
|||
|
||||
test "when enable_props_diff is false, always returns full props" do
|
||||
Application.put_env(:live_svelte, :enable_props_diff, false)
|
||||
|
||||
try do
|
||||
assigns = base_assigns(
|
||||
props: %{"x" => 10, "y" => 20},
|
||||
__changed__: %{props: %{"x" => 10, "y" => 0}}
|
||||
)
|
||||
assigns =
|
||||
base_assigns(
|
||||
props: %{"x" => 10, "y" => 20},
|
||||
__changed__: %{props: %{"x" => 10, "y" => 0}}
|
||||
)
|
||||
|
||||
# In real component rendering, `diff` is present (default true). Ensure global config still wins.
|
||||
assigns = Map.put(assigns, :diff, true)
|
||||
result = LiveSvelte.props_for_payload(assigns)
|
||||
|
|
@ -109,7 +114,9 @@ defmodule LiveSvelte.PropsDiffTest do
|
|||
|> String.replace(""", "\"")
|
||||
|> String.replace("'", "'")
|
||||
|> String.replace("+", "+")
|
||||
|
||||
:json.decode(unescaped)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
|
@ -179,7 +186,13 @@ defmodule LiveSvelte.PropsDiffTest do
|
|||
describe "Tier 3 - ID-based list diffing via object_hash" do
|
||||
test "inserting new item at front with id-list produces fewer ops than N replaces" do
|
||||
items_old = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
|
||||
items_new = [%{id: 4, name: "Dave"}, %{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
|
||||
|
||||
items_new = [
|
||||
%{id: 4, name: "Dave"},
|
||||
%{id: 1, name: "Alice"},
|
||||
%{id: 2, name: "Bob"},
|
||||
%{id: 3, name: "Carol"}
|
||||
]
|
||||
|
||||
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
|
||||
content_ops = Enum.reject(diff, &(&1.op == "test"))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule LiveSvelte.SlotsTest do
|
|||
|
||||
test "filters out slot entries from assigns" do
|
||||
slot_entry = %{__slot__: :button, inner_block: fn -> "content" end}
|
||||
|
||||
assigns = %{
|
||||
name: "Test",
|
||||
button: [slot_entry],
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ defmodule LiveSvelte.StreamsTest do
|
|||
diff = decode_streams_diff(html)
|
||||
|
||||
# Initial render produces replace (empty) + upsert
|
||||
replace_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
|
||||
replace_op =
|
||||
Enum.find(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
|
||||
|
||||
assert replace_op != nil
|
||||
assert Enum.at(replace_op, 2) == []
|
||||
|
||||
|
|
@ -113,9 +115,15 @@ defmodule LiveSvelte.StreamsTest do
|
|||
assert Enum.at(upsert_op, 1) == "/items/-"
|
||||
|
||||
# Order matters: replace MUST come before upsert so the array exists before items are inserted
|
||||
replace_idx = Enum.find_index(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
|
||||
replace_idx =
|
||||
Enum.find_index(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items"
|
||||
end)
|
||||
|
||||
upsert_idx = Enum.find_index(diff, fn op -> Enum.at(op, 0) == "upsert" end)
|
||||
assert replace_idx < upsert_idx, "replace [] must precede upsert ops (got replace at #{replace_idx}, upsert at #{upsert_idx})"
|
||||
|
||||
assert replace_idx < upsert_idx,
|
||||
"replace [] must precede upsert ops (got replace at #{replace_idx}, upsert at #{upsert_idx})"
|
||||
end
|
||||
|
||||
test "insert at 0 produces upsert with path /items/0" do
|
||||
|
|
@ -258,9 +266,11 @@ defmodule LiveSvelte.StreamsTest do
|
|||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
replace_op = Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
|
||||
end)
|
||||
replace_op =
|
||||
Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
|
||||
end)
|
||||
|
||||
assert replace_op != nil, "expected replace op at $$dom_id path for update_only: true"
|
||||
assert Enum.at(replace_op, 1) == "/items/$$items-1"
|
||||
|
||||
|
|
@ -320,9 +330,11 @@ defmodule LiveSvelte.StreamsTest do
|
|||
html = render_html(assigns)
|
||||
diff = decode_streams_diff(html)
|
||||
|
||||
replace_op = Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
|
||||
end)
|
||||
replace_op =
|
||||
Enum.find(diff, fn op ->
|
||||
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
|
||||
end)
|
||||
|
||||
value = Enum.at(replace_op, 2)
|
||||
assert value["__dom_id"] == "items-5"
|
||||
assert value["id"] == 5
|
||||
|
|
|
|||
Loading…
Reference in a new issue