Skip to content

perf(widget): embed from one non-blocking self-contained script tag - #79

Open
Fidasek009 wants to merge 9 commits into
mainfrom
fix/widget-async-embed
Open

Fidasek009 wants to merge 9 commits into
mainfrom
fix/widget-async-embed

Conversation

@Fidasek009

@Fidasek009 Fidasek009 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three changes.

The embed snippet was a parser-blocking <script src>, so any page carrying a Talqo widget stalled its own render on a third-party bundle. It is now <script async>, and the build folds the stylesheet into the bundle, so one tag is the entire embed: one request instead of two, and no window where the widget paints before its styles arrive. Net 112,010 bytes gzip in a single artifact, against 112,810 spread across widget.js and widget.css; the static responder drops its now-dead /widget.css route.

Each public token and access version now lives on an embed with its agent derived server-side, leaving one script attribute and one configuration endpoint (ADR-0013). The SDK already called only /api/embed-config/:token; the extra attribute and route carried their own CORS grant and authentication exemption for no caller.

The shipped CSS is no longer wrapped in cascade layers. A Tailwind host's @layer base preflight was outranking the widget's layered utilities and clearing background, border, font size and padding off the chat input, while the same widget was correct everywhere else. Unlayering our output fixes it with no change to the no-host case.

The load-bearing assumptions each have a test that fails when the thing it guards is removed: the loader defers its mount until the document is parsed, and the shipped stylesheet carries scoped rules in no cascade layer.

How to test

  1. Open an embed in the dashboard and copy its snippet.
  2. Paste it into a page of your own and load that page.
  3. Confirm the launcher appears, that it opens a chat panel, and that the widget uses the colours you configured in the dashboard.
  4. Confirm the host page's own styles are untouched: the widget's CSS must not restyle anything outside .talqo-widget.
  5. Paste the same snippet into a page that loads Tailwind. Confirm the chat input keeps its background, border, font size and padding.
  6. Paste the same snippet into a page served with Content-Security-Policy: style-src 'self', which rejects inline styles. Expect the same result: the widget already sets its palette through an inline style attribute, so it required 'unsafe-inline' before this change too.

Checklist

  • PR title and summary are descriptive.
  • Max one DB migration per PR.
  • Docs updated.
  • Tests included.
  • Architecture-affecting changes follow the architecture guide.
  • I have seen this code, I have run this code, and I take responsibility for this code.

@Fidasek009 Fidasek009 self-assigned this Oct 2, 2026
Comment thread apps/widget/package.json Outdated
Comment thread apps/web/src/routes/dashboard/embeds/-embed-snippet.ts Outdated
Comment thread apps/widget/vite.config.ts Outdated
@Fidasek009
Fidasek009 force-pushed the fix/widget-async-embed branch from 7e8a9d9 to 7d55961 Compare October 2, 2026 12:27
Comment thread apps/e2e/tests/embed.spec.ts Outdated
Comment thread apps/e2e/tests/fixtures/host-early-script.html Outdated
@Fidasek009
Fidasek009 force-pushed the fix/widget-async-embed branch 2 times, most recently from faf9e35 to 5346e64 Compare October 2, 2026 13:59
@Fidasek009
Fidasek009 marked this pull request as ready for review October 2, 2026 14:13
Comment thread apps/widget/vite.config.ts
@kilo-code-bot

kilo-code-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this PR is actually clean. I need to sit down. I had my flamethrower warmed up and everything — ready to find some rogue ensureWidgetStylesheet survivor or an unscoped :root that made it past the scoping gauntlet — but this thing has been through the wringer.

Every commit in this round either tightens the code or removes dead weight. The @layer surgery is complete: @layer base gets the axe (that's your preflight, gone), every other layer gets eviscerated (children promoted), and the remaining selectors are herded under .talqo-widget. The generateBundle scoping runs at post alongside cssInjectedByJs, array-order within the post group means scoping fires before injection, and the bundle test asserts the artifact, not the intent — so a future reorder breaks the test before it breaks a customer page.

The two test removals (widget-config 404 and retired data-talqo-widget) are defensible: they tested history, not behavior, and the canonical endpoints already carry their own coverage. The walkRules selector validation is a proper contract test — parsing the injected stylesheet and checking every non-keyframe selector is scoped.

📊 Overall: Like a chef who keeps finding burnt pancakes in the trash and keeps cooking until they're golden. Every commit in this round tightens the code without introducing new problems.

Files Reviewed (22 files)
  • apps/api/src/app.test.ts
  • apps/api/src/app.ts
  • apps/api/src/http/static.test.ts
  • apps/api/src/http/static.ts
  • apps/api/src/modules/embed/embed.integration.test.ts
  • apps/api/src/modules/embed/embed.routes.test.ts
  • apps/api/src/modules/embed/embed.routes.ts
  • apps/docs/content/docs/embed-chat-integration.mdx
  • apps/e2e/tests/embed.spec.ts
  • apps/e2e/tests/embeds.spec.ts
  • apps/e2e/tests/fixtures/host.html
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.test.ts
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.ts
  • apps/widget/AGENTS.md
  • apps/widget/package.json
  • apps/widget/src/bundle.test.ts
  • apps/widget/src/widget.test.tsx
  • apps/widget/src/widget.tsx
  • apps/widget/vite.config.ts
  • bun.lock
  • docs/adr/0013-own-public-integration-identity-in-embeds.md
  • docs/architecture.md
Previous Review Summaries (13 snapshots, latest commit 5c58cf7)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 5c58cf7)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I had my flamethrower warmed up and everything — ready to roast some unlayered @layer survivors — but the scoping fix is complete, the test assertion correctly widened from "@layer base" to "@layer", and the return after atRule.remove() prevents calling replaceWith on a dead node.

The selector-validation test (bundle.test.ts:49-54) is the kind of defensive regression guard that keeps honest code honest — parsing the injected CSS and checking every non-keyframe selector is scoped. That's a proper contract test.

The two test removals (widget-config 404 and retired data-talqo-widget) are clean: they tested history, not behavior, and the canonical endpoints already carry their own tests.

📊 Overall: Like a chef who keeps finding burnt pancakes in the trash and keeps cooking until they're golden. Every commit in this round tightens the code without introducing new problems.

Files Reviewed (4 files in incremental diff)
  • apps/api/src/modules/embed/embed.routes.test.ts — 0 issues (clean removal)
  • apps/widget/src/bundle.test.ts — 0 issues
  • apps/widget/src/widget.test.tsx — 0 issues (clean removal)
  • apps/widget/vite.config.ts — 0 issues

Previous review (commit ecbbb05)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I had my flamethrower warmed up and everything — ready to roast some unlayered @layer survivors — but the scoping fix is complete, the test assertion correctly widened from "@layer base" to "@layer", and the return after atRule.remove() prevents calling replaceWith on a dead node.

The one thing worth noting: the comment on widgetCssPlugin grew from 1 line to 3. It explains a real architectural subtlety (cascade layers > specificity), so the extra length carries its weight. Would prefer it tightened to 2 lines, but it's not wrong.

📊 Overall: Like finding a PR that doesn't need fixing — rare enough that I'm suspicious, but the logic checks out.

Files Reviewed (2 files)
  • apps/widget/vite.config.ts — 0 issues
  • apps/widget/src/bundle.test.ts — 0 issues

Previous review (commit 5802abb)

Incremental Code Review: No Issues Found

Review scope: Changes from b58f739c to 5802abb (PR #79 — widget async embed).

Summary

The PR moves from a two-file widget distribution (widget.js + widget.css) to a single self-contained script by inlining scoped CSS via vite-plugin-css-injected-by-js. It removes the legacy data-talqo-widget attribute and /api/widget-config/* compatibility endpoint, adds async to the embed snippet, and defers mount until DOMContentLoaded.

Files reviewed (22 changed)

Area Files
API app.ts, app.test.ts, static.ts, static.test.ts, embed.routes.ts, embed.routes.test.ts, embed.integration.test.ts
Widget widget.tsx, widget.test.tsx, bundle.test.ts, vite.config.ts, package.json, AGENTS.md
Web -embed-snippet.ts, -embed-snippet.test.ts
E2E embed.spec.ts, embeds.spec.ts, host.html
Docs embed-chat-integration.mdx, architecture.md, 0013-own-public-integration-identity-in-embeds.md
Config bun.lock

What was checked

  • CSS scoping + inlining: widgetCssPlugin (post-enforced generateBundle) scopes Tailwind output under .talqo-widget; cssInjectedByJs inlines it into the bundle. Bundle test verifies scoped content and absence of unscoped preflight leaks.
  • Mount deferral: Widget defers mount until DOMContentLoaded when document.readyState === "loading", so async scripts do not fail on a missing target element.
  • Legacy removal: data-talqo-widget attribute, ensureWidgetStylesheet, legacyWidgetConfigRoutes, and /api/widget-config/* CORS middleware all removed cleanly with test updates.
  • Snippet: async attribute added to generated embed snippet; test verifies it.
  • Static serving: widget.css removed from WIDGET_PATHS and static responder.
  • All tests updated: Unit, integration, E2E, and bundle tests all reflect the single-file distribution.

Verdict: No issues found. The refactoring is clean, well-tested across all surfaces (unit, integration, E2E, bundle), and correctly removes the old compatibility paths. All entry points (widget, dashboard preview, SDK) remain consistent with the single self-contained script model.

Previous review (commit b58f739)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this PR is actually clean. I need to sit down. I had my flamethrower warmed up and everything.

📊 Overall: Like a chef who burned the first pancake, threw it away, burned the second one less, then served the third one perfectly golden. Five commits of progressively cleaning up everything this review process flagged — plugin ordering verified with mutation tests, comments shortened, tests merged into tighter assertions, docs trimmed of vestigial sentences. The ADR history rewrite was politely declined (and honestly, the reasoning holds water), and the plugin order concern was thoroughly debunked with actual artifact verification. This PR went from "fix the critical bug" to "actually nothing left to fix."

Files Reviewed (23 files)
  • apps/api/src/app.test.ts
  • apps/api/src/app.ts
  • apps/api/src/http/static.test.ts
  • apps/api/src/http/static.ts
  • apps/api/src/modules/embed/embed.integration.test.ts
  • apps/api/src/modules/embed/embed.routes.test.ts
  • apps/api/src/modules/embed/embed.routes.ts
  • apps/api/src/modules/embed/embed.service.ts
  • apps/docs/content/docs/embed-chat-integration.mdx
  • apps/e2e/tests/embed.spec.ts
  • apps/e2e/tests/embeds.spec.ts
  • apps/e2e/tests/fixtures/host.html
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.test.ts
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.ts
  • apps/widget/AGENTS.md
  • apps/widget/package.json
  • apps/widget/src/bundle.test.ts
  • apps/widget/src/widget.test.tsx
  • apps/widget/src/widget.tsx
  • apps/widget/vite.config.ts
  • bun.lock
  • docs/adr/0013-own-public-integration-identity-in-embeds.md
  • docs/architecture.md

Previous review (commit 6ac85c9)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I need to sit down. I had my flamethrower warmed up and everything.

📊 Overall: The "agent-level tokens" sentence was vestigial — every token resolves through an embed now, so describing a data model that doesn't exist is just free confusion. Dropping it is the right call. The test merge is also sharper: checking injected instead of raw bundle text means you're testing what the browser actually applies. Clean incremental work.

Files Reviewed (2 files in incremental diff)
  • apps/docs/content/docs/embed-chat-integration.mdx — 0 issues
  • apps/widget/src/bundle.test.ts — 0 issues

Previous review (commit 3f81244)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I need to sit down. I had my flamethrower warmed up and everything.

📊 Overall: Two files, zero new bugs. The docs drop an "never released" sentence a deployer doesn't care about, and the CSS tests merge into one that checks the injected stylesheet instead of the raw bundle — more precise, less noise. The existsSync import goes with it since nothing loads widget.css anymore. Clean incremental work.

Files Reviewed (2 files in incremental diff)
  • apps/docs/content/docs/embed-chat-integration.mdx — 0 issues
  • apps/widget/src/bundle.test.ts — 0 issues

Previous review (commit 200f2f7)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, an incremental diff of only comment removals and shortenings. I had my roast prepared for actual code — and you gave me a whitespace-and-documentation PR. This is like showing up to a food fight with a salad.

📊 Overall: Eight files, zero functional changes. Every removed comment either repeated what the test name already said or explained something the code itself documents. The remaining comments are shorter and sharper. No bugs introduced, no logic changed, no technical debt added.

Files Reviewed (8 files in incremental diff)
  • apps/api/src/modules/embed/embed.routes.test.ts — removed redundant comment
  • apps/e2e/tests/embed.spec.ts — shortened comment
  • apps/e2e/tests/embeds.spec.ts — removed redundant comment
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.test.ts — removed redundant comment
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.ts — shortened JSDoc
  • apps/widget/src/bundle.test.ts — shortened/removed 3 comments
  • apps/widget/src/widget.test.tsx — shortened comment
  • apps/widget/vite.config.ts — shortened JSDoc

Previous review (commit fa80ce0)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I need to sit down. I had my flamethrower warmed up and everything.

📊 Overall: Like finding a unicorn in production — I didn't think PRs fixed all their issues in a single follow-up, but here we are. The ADR-0017 reference in app.ts now correctly points at ADR-0013, and the ADR status line dropped the non-standard "amended" format. Two issues, two fixes, zero new problems.

Files Reviewed (2 files in incremental diff)
  • apps/api/src/app.ts - 0 issues
  • docs/adr/0013-own-public-integration-identity-in-embeds.md - 0 issues

Previous review (commit 27b80f6)

Verdict: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
💡 suggestion 1
🤏 nitpick 1
Issue Details (click to expand)
File Line Roast
docs/adr/0013-own-public-integration-identity-in-embeds.md 5 ADR-0013 amended in-place and ADR-0017 deleted, rewriting history against ADR conventions
Outside Diff
File Line Issue
apps/api/src/app.ts 49 Comment references deleted ADR-0017; should reference ADR-0013

💀 Worst part: The ADR history rewrite. You had a valid ADR-0017 that the previous review approved. Deleting it and amending ADR-0013 in-place erases the paper trail instead of correcting it.

📊 Overall: Like a student who got an A on a paper, then emailed the professor asking to delete the grade from the record because "the citation was never actually published." The reasoning is sound, the method is not.

Files Reviewed (5 incremental files)
  • apps/api/src/modules/embed/embed.routes.test.ts - 0 issues
  • apps/docs/content/docs/embed-chat-integration.mdx - 0 issues
  • apps/widget/src/widget.test.tsx - 0 issues
  • docs/adr/0013-own-public-integration-identity-in-embeds.md - 1 issue
  • docs/adr/0017-drop-legacy-embed-compatibility-adapters.md - deleted

Previous review (commit 20aa1b5)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I had my flamethrower warmed up and everything.

📊 Overall: Clean removal of the legacy compatibility adapters. The ADR files are well-documented, the test coverage is appropriate (the widget test for the retired attribute is a nice defensive guard), and the API surface shrinks cleanly. No dangling references, no forgotten CORS middleware, no auth holes.

Files Reviewed (11 files in incremental diff)
  • apps/api/src/app.test.ts - 0 issues
  • apps/api/src/app.ts - 0 issues
  • apps/api/src/modules/embed/embed.integration.test.ts - 0 issues
  • apps/api/src/modules/embed/embed.routes.test.ts - 0 issues
  • apps/api/src/modules/embed/embed.routes.ts - 0 issues
  • apps/api/src/modules/embed/embed.service.ts - 0 issues
  • apps/docs/content/docs/embed-chat-integration.mdx - 0 issues
  • apps/widget/src/widget.test.tsx - 0 issues
  • apps/widget/src/widget.tsx - 0 issues
  • docs/adr/0013-own-public-integration-identity-in-embeds.md - 0 issues
  • docs/adr/0017-drop-legacy-embed-compatibility-adapters.md - 0 issues

Previous review (commit d9bb82f)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this PR is actually clean. I need to sit down. I had my flamethrower warmed up and everything.

📊 Overall: Like finding a unicorn in production — I didn't think clean PRs existed anymore, but here we are. The "ships no unscoped css" test at bundle.test.ts:50 is exactly the kind of defensive regression guard that keeps honest code honest.

Files Reviewed (16 files)
  • apps/api/src/http/static.test.ts
  • apps/api/src/http/static.ts
  • apps/docs/content/docs/embed-chat-integration.mdx
  • apps/e2e/tests/embed.spec.ts
  • apps/e2e/tests/embeds.spec.ts
  • apps/e2e/tests/fixtures/host.html
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.test.ts
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.ts
  • apps/widget/AGENTS.md
  • apps/widget/package.json
  • apps/widget/src/bundle.test.ts
  • apps/widget/src/widget.test.tsx
  • apps/widget/src/widget.tsx
  • apps/widget/vite.config.ts
  • bun.lock
  • docs/architecture.md

Previous review (commit 5d027b7)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental diff is actually clean. I had my flamethrower warmed up and everything.

📊 Overall: Like finding a unicorn in production — I didn't think clean PRs existed anymore, but here we are. The "ships no unscoped css" test at bundle.test.ts:50 is exactly the kind of defensive regression guard that keeps honest code honest.

Files Reviewed (1 file in incremental diff)
  • apps/widget/src/bundle.test.ts - 0 issues

Previous review (commit 5346e64)

Verdict: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
🚨 critical 1
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0
Issue Details (click to expand)
File Line Roast
apps/widget/vite.config.ts 91 enforce: "post" makes the scoping plugin run after cssInjectedByJs, so CSS is injected un-scoped. The comment says "before" but the code does "after."

🏆 Best part: The mount-deferral test (bundle.test.ts:48-68) is genuinely clever — cleanly mocking readyState with a proper finally restore. That's how you test async-script behavior without a browser. Well played.

💀 Worst part: The plugin ordering bug undoes the entire point of the PR. If this ships, every customer page gets un-scoped Tailwind global styles leaking through the widget. The CSS injection works, but the scoping doesn't — so you traded two requests for one, but the one request now trashes host pages.

📊 Overall: Like cooking a gourmet meal and then serving it on a dirty plate. The substance is right, but a single line of enforce: "post" ruins the whole experience.

Files Reviewed (14 files)
  • apps/docs/content/docs/embed-chat-integration.mdx - 0 issues
  • apps/e2e/tests/embed.spec.ts - 0 issues
  • apps/e2e/tests/embeds.spec.ts - 0 issues
  • apps/e2e/tests/fixtures/host.html - 0 issues
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.test.ts - 0 issues
  • apps/web/src/routes/dashboard/embeds/-embed-snippet.ts - 0 issues
  • apps/widget/AGENTS.md - 0 issues
  • apps/widget/package.json - 0 issues
  • apps/widget/src/bundle.test.ts - 0 issues
  • apps/widget/src/widget.test.tsx - 0 issues
  • apps/widget/src/widget.tsx - 0 issues
  • apps/widget/vite.config.ts - 1 issue
  • bun.lock - 0 issues
  • docs/architecture.md - 0 issues

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4-flash · Input: 141.5K · Output: 12.5K · Cached: 970K

@Fidasek009
Fidasek009 force-pushed the fix/widget-async-embed branch 3 times, most recently from 20aa1b5 to 27b80f6 Compare October 3, 2026 10:56
Comment thread docs/adr/0013-own-public-integration-identity-in-embeds.md Outdated
@Fidasek009
Fidasek009 force-pushed the fix/widget-async-embed branch 3 times, most recently from b58f739 to 5802abb Compare October 6, 2026 13:44
The generated snippet was parser-blocking, so any page carrying a Talqo
widget stalled its own render on a third-party bundle. The loader already
tolerates late execution: document.currentScript stays populated while an
async classic script runs, and it defers mounting until the document is
parsed.

The build now folds the stylesheet into the bundle, so one tag is the whole
embed: one request instead of two, and no window where the widget could
paint before its styles arrived. That drops ensureWidgetStylesheet along
with the URL rewriting that had to version-match the stylesheet by hand.
It adds no CSP requirement: the widget already applies its palette through
an inline style prop.

Net 111,810 bytes gzip in a single request, against 112,810 across two. The
static responder drops its /widget.css route, which no longer exists.

Co-Authored-By: opencode (noreply@opencode.ai)
Store each public token and access version on an embed and derive its
agent server-side, so the public surface is a single script attribute and
a single configuration endpoint. The SDK already calls only
`/api/embed-config/:token`; the extra attribute and route carried their own
CORS grant and authentication exemption for no caller.

ADR-0013 states the decision.

Co-Authored-By: opencode (noreply@opencode.ai)
Drop the ones a test name already carries, and cut the rest to one line.
The four that stay each record something not derivable from the code:
plugin order decides whether the shipped CSS is scoped, Tailwind expands
CSS after any transform hook, and honouring the retired embed attribute
would fire a request.

Co-Authored-By: opencode (noreply@opencode.ai)
A deployer copies a generated snippet and does not care that an
unreleased attribute once existed, so that sentence goes. What is left is
what they actually decide: keep async, and when to omit data-talqo-api.

The two CSS tests collapse into one that reads the injected stylesheet
rather than the bundle text, which is both more precise and shorter, and
the assertion that no widget.css file is emitted goes with it: nothing
loads that file, so its absence is a layout detail rather than behaviour.

Co-Authored-By: opencode (noreply@opencode.ai)
Every embed token resolves through the embed, and an embed selects one agent,
so there is no agent-level token for a reader to wonder about. The sentence
described a distinction the data model no longer makes.

Co-Authored-By: opencode (noreply@opencode.ai)
Access is declared per route with access.public, so the old "authentication
exemption" wording no longer describes the mechanism.

Co-Authored-By: opencode (noreply@opencode.ai)
Cascade layers resolve before specificity, so leaving our utilities in
Tailwind's @layer utilities let a host's @layer base preflight win: background,
border, font-size and padding all reset on a Tailwind page while the same
widget was correct on any other host.

Unlayering every non-base block is the whole fix. Verified against a real
Tailwind v4 stylesheet: the widget now renders identically with and without
one on the page.

Co-Authored-By: opencode (noreply@opencode.ai)
The 404 for a removed route and the network silence for a retired
attribute described history rather than behaviour. The canonical
embed-config endpoint and the no-token mount already carry their own
tests, so both removals lose nothing.

The CSS check now asserts the shipped stylesheet directly: it contains
scoped rules, it participates in no cascade layer, and every selector it
carries is scoped to the widget. The leak strings it replaced named
specific past outputs.

Co-Authored-By: opencode (noreply@opencode.ai)
@Fidasek009
Fidasek009 force-pushed the fix/widget-async-embed branch from 1b16692 to f5e637c Compare October 8, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant