From 49a24973fa8c8ca5d89cee604ab1647b0ad45353 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 4 May 2026 01:51:05 -0700 Subject: [PATCH] feat: add `client` rendering strategy for islands --- Island.cfm | 52 ++++++++++++++++++---------- README.md | 13 +++++++ coldspa/renderers/React.cfm | 5 +-- coldspa/renderers/Vue.cfm | 5 +-- coldspa/vite/clients/react-client.js | 14 ++++++-- coldspa/vite/clients/vue-client.js | 11 +++--- 6 files changed, 69 insertions(+), 31 deletions(-) diff --git a/Island.cfm b/Island.cfm index be6b8d2..3ddd27f 100644 --- a/Island.cfm +++ b/Island.cfm @@ -8,7 +8,8 @@ framework (struct, required) - renderer struct from /coldspa/renderers/* path (string, required) - component path (e.g. "./App.vue") props (struct, optional) - props passed to the component (default {}) - strategy (string, optional) - "load" | "idle" | "visible" (default "load") + strategy (string, optional) - "load" | "idle" | "visible" | "client" (default "load") + "client" skips SSR entirely (no server HTML, no CSS pre-render) ---> @@ -22,14 +23,17 @@ if (thisTag.executionMode neq "start") { } // --- validate strategy -validStrategies = ["load", "idle", "visible"]; +validStrategies = ["load", "idle", "visible", "client"]; if (!arrayFindNoCase(validStrategies, attributes.strategy)) { throw( type = "Coldspa.InvalidStrategy", - message = "Invalid hydration strategy '#attributes.strategy#'. Must be one of: load, idle, visible." + message = "Invalid hydration strategy '#attributes.strategy#'. Must be one of: load, idle, visible, client." ); } +// "client" strategy is client-only: skip SSR entirely. +clientOnly = (attributes.strategy == "client"); + // --- validate renderer shape if (!structKeyExists(attributes.framework, "render") || !isCustomFunction(attributes.framework.render)) { throw( @@ -92,24 +96,15 @@ if (structKeyExists(attributes.framework, "clientEntry")) { resolvedClientEntry = resolveAsset(attributes.framework.clientEntry); } -rendered = attributes.framework.render(mountId, componentGlobKey, propsJson, resolvedClientEntry); - -// Backwards-compat: allow renderers that still return a plain string (treated as body, no imports) -if (isSimpleValue(rendered)) { - rendered = { "imports": "", "body": rendered }; -} -bootImports = rendered.imports; -bootBody = rendered.body; - // Server-side rendering. If the renderer supports ssrRender(), call the SSR -// sidecar and embed the returned HTML inside the mount div. The client's -// createSSRApp().mount() will hydrate it on load. If SSR fails or isn't -// available, we just emit an empty div and the client mounts fresh -- so the -// page works either way (just no JS-off rendering in that case). +// sidecar and embed the returned HTML inside the mount div. The client uses +// the hydrate flag (computed from whether SSR actually produced HTML) to pick +// between hydrate-mode and fresh client-mount APIs. If SSR fails or isn't +// available, the page still works -- just no pre-rendered markup. ssrHtml = ""; ssrCss = ""; ssrError = ""; -if (structKeyExists(attributes.framework, "ssrRender")) { +if (!clientOnly && structKeyExists(attributes.framework, "ssrRender")) { ssrResult = attributes.framework.ssrRender(componentGlobKey, attributes.props); // Tolerate older renderers that returned a plain string. if (isStruct(ssrResult)) { @@ -121,6 +116,23 @@ if (structKeyExists(attributes.framework, "ssrRender")) { } } +// Build the options object passed to the client's mount() function. The +// client uses `strategy` to decide between hydrate-mode and fresh-mount APIs; +// SSR is skipped entirely above when strategy="client", so anything else +// implies SSR markup is present. +mountOptionsJson = serializeJSON({ + "strategy": attributes.strategy +}); + +rendered = attributes.framework.render(mountId, componentGlobKey, propsJson, resolvedClientEntry, mountOptionsJson); + +// Backwards-compat: allow renderers that still return a plain string (treated as body, no imports) +if (isSimpleValue(rendered)) { + rendered = { "imports": "", "body": rendered }; +} +bootImports = rendered.imports; +bootBody = rendered.body; + // In prod the SSR sidecar can't easily inline component CSS (the SSR build // doesn't emit CSS), so we surface it as tags from the client manifest. // Each chunk in the manifest carries a `css` array of hashed asset filenames. @@ -144,7 +156,9 @@ if (!cfg.isDev && len(ssrHtml) && structKeyExists(attributes.framework, "clientE - + + + @@ -166,7 +180,7 @@ if (!cfg.isDev && len(ssrHtml) && structKeyExists(attributes.framework, "clientE - +