Closed donferi closed 11 months ago
Hey there @donferi, thanks for the issue!
I like the idea of adding some helpers to make this more clear, but this is possible today doing something like the below. Would love to hear any thoughts on API design / what the best experience would be for this.
So, you can co-locate TS files anywhere, including in the pages
folder, just by excluding the .page.
in the file name.
If you want to use a function client-side, you can create some super simple helpers for various use cases.
For example, here is an arbitrary function you want to run on the client, originating from a TS file inside pages
:
src/pages/client-script.ts
function alertYo() {
alert("Yo this is from src/pages/client-script.ts!")
}
export { alertYo }
For a button inside a page component, you can do:
src/pages/_index.page.tsx
import { alertYo } from "./client-script.ts"
function toClientHandler(fn: () => void) {
return `${fn}${fn.name}();`
}
export default function () {
return (
<button onClick={toClientHandler(alertYo)}>Click me</button>
)
}
And if you want to run a script eagerly, you can do:
import { alertYo } from "./client-script.ts"
function toClientHandler(fn: () => void) {
return `${fn}${fn.name}();`
}
function RunDangerouslyAndEagerlyOnClient({ fn }: { fn: () => void }) {
return <script dangerouslySetInnerHTML={{ __html: toClientHandler(fn) }} />
}
export default function () {
return (
<>
<RunDangerouslyAndEagerlyOnClient fn={alertYo} />
<OtherMarkup />
</>
)
}
What do you think, @donferi?
Hey @sjc5 thanks for your fast reply!
I appreciate your approach, got the alert working! Thank you! Btw I tried this with bun and it didn't work, but worked great on node!
My only hesitation with this approach is that since its getting the literal fn string you lose things like scope and passing args around is a bit trickier too.
For eg:
let count = 0;
function alertYo() {
alert(`Yo this is from src/pages/client-script.ts! Count: ${count}`)
}
export { alertYo }
I'm thinking on how this could be done but I'm running out of ideas 😅. It could be that this gets added as a <script>
after processing but then we wouldn't be able to reference the fn in the server jsx 🤔
I see what you mean @donferi.
What do you think of this? This one should work in Bun too.
src/pages/client-script.ts
/// <reference lib="dom" />
let count = 0;
function increment(elementId: string) {
count++;
const countEl = document.getElementById(elementId);
if (!countEl) {
return;
}
countEl.innerHTML = count.toString();
}
export { count, increment };
src/pages/_index.page.tsx
import { count, increment } from "./client-script.js";
// potentially from "@hwy-js/client"
import { InitClientScope, toClientHandler } from "./client-utils.js";
export default function () {
const elementId = "count";
return (
<>
<InitClientScope values={{ count, increment }} />
<button onclick={toClientHandler(increment, elementId)}>Click me</button>
<div id={elementId}>{count}</div>
</>
);
}
Note that toClientHandler
above takes the function as the first param, and then any arguments to that function as additional rest params (typesafe from the generic).
This could potentially be baked into the framework and imported like:
import { InitClientScope, toClientHandler } from "@hwy-js/client"
src/pages/client-utils.tsx
// NOTE: Would need to be robustified for different value types to avoid `[object Object]`
function InitClientScope({ values }: { values: Record<string, any> }) {
return (
<script
dangerouslySetInnerHTML={{
__html: `${Object.entries(values)
.map(([key, value]) => `let ${key} = ${value}`)
.join(";\n")}`,
}}
/>
);
}
function toClientHandler<Args extends any[]>(
fn: (...args: Args) => void,
...args: Args
): string {
const argsStr =
args.length > 0 ? args.map((arg) => JSON.stringify(arg)).join(", ") : "";
return `${fn.name}(${argsStr});`;
}
export { InitClientScope, toClientHandler };
A lot of this could certainly be accomplished through new build-step magic, but that opens a bit of a can of worms around things like scope and code splitting, and might detract a bit from the magic of the HTMX mental model. I'm not sure if doing this in the build step would be worth the extra complexity to the framework architecture, but I'm definitely open to thinking more about it. I think with things like HTMX you want to usually rely on server-side state, and then reach for things like Alpine, or perhaps utils like the ones proposed above, when you need client interactivity. It's kind of a mental model shift from React, etc.
But that said, some utilities / patterns like the above (or whatever we land on) would be nice for those times when you do want to do purely client side stuff, but don't want to reach for Alpine.
What do you think, @donferi?
Actually I see what's happening -- the above has an issue in Bun too because for some reason it is stripping the function name. Will have to get that sorted, not sure why it's doing that only in Bun... they both should be using the same esbuild settings 🤔
@sjc5 Thanks you so much again!
I tried this and this does work! I'm having the same realization than you, I'm honestly not sure if its worth it.
I'm not a fan of the Alpine + JSX combo because things like @click
or x-on:click.once
are not valid jsx 😕
What about keeping it simple and doing something like about.client.ts
and that gets processed and included in a <script>
on about.page.tsx
? 🤔
In there we can just do vanilla JS, add event listeners manually instead of trying to pass a "reference" around. This way we can at least write some TS and include it on a specific page without over-complicating everything and deviating from htmx
too much.
I'm just not sure how it works with things like hx-boost
, ideally we unload the page scripts we are no longer using.
What love to hear your thoughts on this.
I think that's probably do-able @donferi
One approach (pseudo-code) could be:
let Component = pageExport.default
if (fileWithSameNameDotClient) {
Component = (props: CompProps) => {
return (
<>
<ExtraClientScript />
{await pageExport.default(props)}
</>
)
}
}
One problem with the above is that it makes the script a sibling to the page component, not a child. But we don't want to mess with component structure or add extra wrapper elements.
Could also probably toss it right into the document head. We wouldn't be able to bundle because that would either limit deployment targets or require doing a bunch of permutational work at build time. But could just do sequential script tags in the document head for all live page components. This approach would avoid the component structure problem above too.
That sounds like a perfectly reasonable first iteration to me. 👍
Nice, thanks for talking it through! I'll give this a crack when I get some time. It shouldn't be too long to proof of concept I don't think.
Confirming that we can make this API work:
src/pages/whatever.client.ts
/// <reference lib="dom" />
const el = document.getElementById("button");
let count = 0;
function increment() {
if (el) {
count++;
el.innerHTML = count.toString();
}
}
src/pages/whatever.page.tsx
export default function () {
return (
// Number will increment when you click
<button id="button" onclick="increment();">
0
</button>
);
}
If you do this across multiple nested routes, the scripts will order from highest ancestor to lowest child in the document head. It will be the developer's responsibility to avoid overwriting variables in various scripts, as these scripts essentially become global scope, but that seems reasonable to me. It also makes it nice in that you can just do onclick="increment();"
in your JSX and it will work.
Even though this solution is not too far off from just doing the same at a global level in your main client.entry.ts
, it enables you to (1) do what is more or less manual code splitting and (2) co-locate your client scripts.
As you stated above, this should be mostly an escape hatch and not a core way of working. If it became a core way of working I'd probably reach back for React / Remix 🙂
Hello, is there a way to introduce something like
filename.client.ts
sibling to a*.page.tsx
so that that client file gets transpiled and automatically added to a page?Sometimes I need some client side js here and there and would love to have a clean way to add it per-page.
I would love to be able to do something like
onClick={someFnInFilename.client.ts}
. It would be even better if I can import it so that I don't have to use globals likewindow.someFnInFilename
but that may be impossible to implement.Thanks!!